{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loading-07",
  "title": "Loading gate",
  "description": "A gate that holds a skeleton the size of the content and crossfades into it without moving the layout, with an error state and Retry that keep the size.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/card.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/label.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/switch.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/loading/loading-07.tsx",
      "content": "'use client';\n\nimport { createContext, useContext, useEffect, useRef, useState } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport {\n  PRESS_LEAD_MS,\n  pressedQuietOf,\n  spokenUnless,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport { CircleAlertIcon, RefreshCwIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\nimport { Badge } from '@/registry/ndeko/ui/badge';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Card } from '@/registry/ndeko/ui/card';\nimport { Label } from '@/registry/ndeko/ui/label';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { Switch } from '@/registry/ndeko/ui/switch';\n\ntype SweepLight = 'sweeping' | 'still';\n\nconst SweepContext = createContext<SweepLight>('still');\n\nconst BAND_WIDTH_PX = 280;\nconst SPEED_PX_PER_MS = 0.7;\nconst PASS_SHARE = 0.72;\n\nconst BLOCK_BASE = [\n  'relative block overflow-hidden rounded-md',\n  '[--sweep-light:color-mix(in_oklab,var(--card)_85%,transparent)] dark:[--sweep-light:color-mix(in_oklab,var(--foreground)_10%,transparent)]',\n].join(' ');\n\nconst BAND =\n  'pointer-events-none absolute inset-y-0 left-0 w-[280px] bg-[linear-gradient(90deg,transparent,var(--sweep-light),transparent)]';\n\nfunction tintOf(light: SweepLight) {\n  if (light === 'sweeping') {\n    return 'bg-foreground/[0.06]';\n  }\n  return 'bg-foreground/[0.08]';\n}\n\nfunction lightOf(isSweeping: boolean): SweepLight {\n  if (isSweeping) {\n    return 'sweeping';\n  }\n  return 'still';\n}\n\nfunction keyframesOf(blockLeft: number, viewportWidth: number): Keyframe[] {\n  const start = -BAND_WIDTH_PX - blockLeft;\n  const end = viewportWidth - blockLeft;\n  const from = `${start}px 0`;\n  const to = `${end}px 0`;\n  return [\n    { translate: from, offset: 0 },\n    { translate: to, offset: PASS_SHARE },\n    { translate: to, offset: 1 },\n  ];\n}\n\nfunction periodOf(viewportWidth: number) {\n  const travel = viewportWidth + BAND_WIDTH_PX;\n  const passMs = travel / SPEED_PX_PER_MS;\n  return Math.round(passMs / PASS_SHARE);\n}\n\n/**\n * Every band runs on the document timeline (start time 0), so all of them are at the same phase,\n * and each is shifted by the left edge of its own block, so they draw one light across the page.\n */\nfunction SweepBand() {\n  const bandRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const band = bandRef.current;\n    const block = band?.parentElement;\n    if (!band || !block) {\n      return;\n    }\n    let animation: Animation | undefined;\n\n    const restart = () => {\n      animation?.cancel();\n      const { left } = block.getBoundingClientRect();\n      const width = window.innerWidth;\n      const keyframes = keyframesOf(left, width);\n      animation = band.animate(keyframes, {\n        duration: periodOf(width),\n        iterations: Infinity,\n      });\n      animation.startTime = 0;\n    };\n\n    restart();\n    const observer = new ResizeObserver(restart);\n    observer.observe(block);\n    window.addEventListener('resize', restart);\n\n    return () => {\n      animation?.cancel();\n      observer.disconnect();\n      window.removeEventListener('resize', restart);\n    };\n  }, []);\n\n  return <span ref={bandRef} aria-hidden='true' className={BAND} />;\n}\n\ntype SweepScopeProps = {\n  isActive: boolean;\n  children: React.ReactNode;\n};\n\nexport function SweepScope({ isActive, children }: SweepScopeProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const isSweeping = isActive && !shouldReduceMotion;\n  const light = lightOf(isSweeping);\n\n  return (\n    <SweepContext.Provider value={light}>{children}</SweepContext.Provider>\n  );\n}\n\nexport function SweepBlock({ className }: { className?: string }) {\n  const light = useContext(SweepContext);\n  const isSweeping = light === 'sweeping';\n\n  return (\n    <span\n      data-slot='sweep-block'\n      aria-hidden='true'\n      className={cn(BLOCK_BASE, tintOf(light), className)}\n    >\n      {isSweeping && <SweepBand />}\n    </span>\n  );\n}\n\nexport type GateStatus = 'loading' | 'ready' | 'error';\n\nconst LAYER =\n  'transition-opacity duration-200 ease-(--ease-out-strong) motion-reduce:duration-150';\n\nconst HIDDEN_LAYER = 'pointer-events-none opacity-0';\n\nconst SHOWN_LAYER = 'opacity-100';\n\nfunction layerStateOf(isShown: boolean) {\n  if (isShown) {\n    return SHOWN_LAYER;\n  }\n  return HIDDEN_LAYER;\n}\n\ntype LoadingGateProps = {\n  status: GateStatus;\n  skeleton: React.ReactNode;\n  /** The real content. It is always laid out, which is what keeps the box the same size. */\n  children: React.ReactNode;\n  onRetry: () => void;\n  errorTitle?: string;\n  errorHint?: string;\n  retryClassName?: string;\n  className?: string;\n};\n\nexport function LoadingGate({\n  status,\n  skeleton,\n  children,\n  onRetry,\n  errorTitle = 'Could not load this',\n  errorHint = 'Check your connection, then try again.',\n  retryClassName,\n  className,\n}: LoadingGateProps) {\n  const isLoading = status === 'loading';\n  const isReady = status === 'ready';\n  const isFailed = status === 'error';\n\n  return (\n    <div\n      data-slot='loading-gate'\n      data-status={status}\n      aria-busy={isLoading}\n      className={cn('relative min-h-44', className)}\n    >\n      <div inert={!isReady} className={cn(LAYER, layerStateOf(isReady))}>\n        {children}\n      </div>\n      <div\n        aria-hidden='true'\n        className={cn('absolute inset-0', LAYER, layerStateOf(isLoading))}\n      >\n        <SweepScope isActive={isLoading}>{skeleton}</SweepScope>\n      </div>\n      <div\n        inert={!isFailed}\n        className={cn(\n          'absolute inset-0 flex flex-col items-center justify-center gap-3 text-center',\n          LAYER,\n          layerStateOf(isFailed)\n        )}\n      >\n        <span className='bg-destructive/10 text-destructive-ink flex size-9 items-center justify-center rounded-lg'>\n          <CircleAlertIcon className='size-4' />\n        </span>\n        <div className='flex flex-col gap-1'>\n          <p className='text-sm font-medium'>{errorTitle}</p>\n          <p className='text-muted-foreground text-xs'>{errorHint}</p>\n        </div>\n        <Button\n          variant='outline'\n          size='sm'\n          onClick={onRetry}\n          className={retryClassName}\n        >\n          <RefreshCwIcon className='size-3.5 opacity-60' />\n          Retry\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nconst FAST_LOAD_MS = 900;\nconst SLOW_LOAD_MS = 3200;\n\nfunction loadDurationOf(isSlow: boolean) {\n  if (isSlow) {\n    return SLOW_LOAD_MS;\n  }\n  return FAST_LOAD_MS;\n}\n\nfunction outcomeOf(shouldFail: boolean): GateStatus {\n  if (shouldFail) {\n    return 'error';\n  }\n  return 'ready';\n}\n\nconst STATUS_MESSAGES: Record<GateStatus, string> = {\n  loading: 'Loading the invoice',\n  ready: 'Invoice loaded',\n  error: 'The invoice could not be loaded. Retry is available',\n};\n\nfunction InvoiceSkeleton() {\n  return (\n    <div className='flex h-full flex-col gap-4'>\n      <div className='flex items-center gap-3'>\n        <SweepBlock className='size-10 shrink-0 rounded-full' />\n        <div className='flex flex-1 flex-col gap-2'>\n          <SweepBlock className='h-3.5 w-2/5 rounded-full' />\n          <SweepBlock className='h-3 w-1/4 rounded-full' />\n        </div>\n      </div>\n      <div className='flex flex-col gap-2'>\n        <SweepBlock className='h-7 w-1/3 rounded-md' />\n        <SweepBlock className='h-3 w-1/4 rounded-full' />\n      </div>\n      <div className='flex flex-col gap-2.5'>\n        <SweepBlock className='h-3.5 w-full rounded-full' />\n        <SweepBlock className='h-3.5 w-full rounded-full' />\n        <SweepBlock className='h-3.5 w-full rounded-full' />\n      </div>\n    </div>\n  );\n}\n\nfunction InvoiceRow({\n  label,\n  children,\n}: {\n  label: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <div className='flex h-5 items-center justify-between gap-3 text-[13px]'>\n      <dt className='text-muted-foreground'>{label}</dt>\n      <dd className='flex items-center tabular-nums'>{children}</dd>\n    </div>\n  );\n}\n\nfunction InvoiceSummary() {\n  return (\n    <div className='flex flex-col gap-4'>\n      <div className='flex items-center gap-3'>\n        <Avatar seed='Amani Kasongo' className='size-10' />\n        <div className='flex min-w-0 flex-col'>\n          <p className='truncate text-sm font-medium'>Amani Kasongo</p>\n          <p className='text-muted-foreground text-xs'>Invoice INV-2048</p>\n        </div>\n      </div>\n      <div className='flex flex-col'>\n        <p className='text-2xl font-semibold tabular-nums'>$1,284.00</p>\n        <p className='text-muted-foreground text-xs'>Illustrative amount</p>\n      </div>\n      <dl className='flex flex-col gap-2'>\n        <InvoiceRow label='Issued'>12 Nov 2026</InvoiceRow>\n        <InvoiceRow label='Due'>26 Nov 2026</InvoiceRow>\n        <InvoiceRow label='Status'>\n          <Badge variant='success'>Paid</Badge>\n        </InvoiceRow>\n      </dl>\n    </div>\n  );\n}\n\nexport default function Loading07() {\n  const slowId = 'loading-07-slow';\n  const failId = 'loading-07-fail';\n  const [status, setStatus] = useState<GateStatus>('ready');\n  const [isSlow, setIsSlow] = useState(false);\n  const [shouldFail, setShouldFail] = useState(false);\n  const { pressed, press } = usePress<'reload' | 'retry'>();\n  const { schedule, clearAll } = useTimeouts();\n\n  function load() {\n    clearAll();\n    setStatus('loading');\n    const duration = loadDurationOf(isSlow);\n    const outcome = outcomeOf(shouldFail);\n    schedule(() => setStatus(outcome), duration);\n  }\n\n  function startOver() {\n    clearAll();\n    setStatus('ready');\n    setIsSlow(false);\n    setShouldFail(false);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2600, run: () => press('reload') },\n    { after: PRESS_LEAD_MS, run: load },\n    { after: 2200, run: () => setIsSlow(true) },\n    { after: 800, run: () => press('reload') },\n    { after: PRESS_LEAD_MS, run: load },\n    { after: 4600, run: () => setIsSlow(false) },\n    { after: 600, run: () => setShouldFail(true) },\n    { after: 800, run: () => press('reload') },\n    { after: PRESS_LEAD_MS, run: load },\n    { after: 2600, run: () => setShouldFail(false) },\n    { after: 900, run: () => press('retry') },\n    { after: PRESS_LEAD_MS, run: load },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 3400,\n    onReset: startOver,\n  });\n\n  useKeptHeight(ref);\n\n  const isLoading = status === 'loading';\n  const message = spokenUnless(isAutoplaying, STATUS_MESSAGES[status]);\n\n  return (\n    <div\n      ref={ref}\n      className='flex min-h-[25rem] w-full max-w-sm flex-col gap-3'\n    >\n      <Card className='px-5'>\n        <LoadingGate\n          status={status}\n          skeleton={<InvoiceSkeleton />}\n          onRetry={load}\n          errorTitle='Could not load the invoice'\n          errorHint='The connection dropped. Nothing was lost.'\n          retryClassName={pressedQuietOf(pressed === 'retry')}\n          className='min-h-[12.25rem]'\n        >\n          <InvoiceSummary />\n        </LoadingGate>\n      </Card>\n      <div className='flex flex-wrap items-center gap-x-4 gap-y-2 px-1'>\n        <div className='flex items-center gap-2'>\n          <Switch id={slowId} checked={isSlow} onCheckedChange={setIsSlow} />\n          <Label htmlFor={slowId} className='text-xs'>\n            Simulate slow\n          </Label>\n        </div>\n        <div className='flex items-center gap-2'>\n          <Switch\n            id={failId}\n            checked={shouldFail}\n            onCheckedChange={setShouldFail}\n          />\n          <Label htmlFor={failId} className='text-xs'>\n            Simulate error\n          </Label>\n        </div>\n        <Button\n          variant='outline'\n          size='sm'\n          disabled={isLoading}\n          onClick={load}\n          className={cn('ms-auto', pressedQuietOf(pressed === 'reload'))}\n        >\n          <RefreshCwIcon className='size-3.5 opacity-60' />\n          Reload\n        </Button>\n      </div>\n      <LiveRegion message={message} />\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "loading"
  ],
  "type": "registry:component"
}