{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "preview-stage",
  "title": "Preview stage",
  "description": "A measured preview frame: a width switch with a ruler and shutters, scale to fit, light and dark, reload and open in a new tab, plus loading and error states with try again.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/preview-stage/preview-stage.tsx",
      "content": "// Preview stage: shows a demo at a chosen width, like a measuring instrument. A ruler with two caliper jaws and a\n// readout says how wide the frame is, and two shutters close in from the sides when the frame is narrower than the\n// window. When the frame is wider than the window (1440 in a 700 px column), the content is scaled down to fit with\n// a transform, so you still see the whole layout; the note under the window says by how much.\n//\n// Content is either a page in an iframe (`src`; the theme goes into its URL through `themeParam`, by default as\n// `mode=light|dark`) or your own `children`. Plain children get the `dark` class in dark, so token-based content\n// follows; to show light content on a dark page, pass a function, which gets the width and theme to apply yourself.\n// Above the window: the width switch, a light/dark switch for the content, reload, and (for `src`) open in a new tab.\n//\n// States: loading shows a thin bar, but only after 300 ms, so a quick load never flashes; ready fades the content\n// in; error says so and offers Try again. An iframe on this origin is checked when it loads (the browser's error\n// page or an HTTP error status counts as failed), and any iframe that has not loaded `timeout` ms after it came into\n// view is an error. Pass `status` to drive the states yourself, and `onReload` to hear Reload and Try again.\n//\n// Keyboard: the width switch is a radio group (arrow keys). While focus is inside the stage, W steps the width\n// (Shift+W back) and T flips the theme; `shortcuts={false}` turns both off, hides their hints and drops\n// `aria-keyshortcuts` (WCAG 2.1.4). Width and theme changes, and a failed load, are announced politely. Try again\n// hands focus to Reload, since the button it was on goes away.\n//\n// Motion: jaws and shutters travel 300 ms, the width indicator 280 ms, the switch knob 260 ms, the readout digits\n// roll; all transform, all ease-out-quint. A change made from the keyboard jumps straight there, and so does\n// everything under reduced motion (the loader then shows a still bar).\nimport { ArrowUpRight, RotateCw } from 'lucide-react'\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n} from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type StageTheme = 'light' | 'dark'\nexport type StageStatus = 'loading' | 'ready' | 'error'\n\nexport type PreviewStageLabels = {\n  /** The label of the width switch. */\n  width: string\n  /** The unit after each width, read out with it (\"320 px\"). */\n  px: string\n  dark: string\n  light: string\n  /** What the switch turns on; read after \"Dark Light:\". */\n  themeSwitch: string\n  reload: string\n  /** The open-in-a-new-tab link (only with `src`). */\n  open: string\n  loading: string\n  error: string\n  retry: string\n  /** The note under the window. `percent` is below 100 when the frame is scaled to fit. */\n  frame: (width: number, percent: number) => string\n  /** Announced when the width changes. */\n  announceWidth: (width: number) => string\n  /** Announced when the theme changes. */\n  announceTheme: (theme: StageTheme) => string\n}\n\nconst LABELS: PreviewStageLabels = {\n  width: 'Width',\n  px: 'px',\n  dark: 'Dark',\n  light: 'Light',\n  themeSwitch: 'light theme in the frame',\n  reload: 'Reload the preview',\n  open: 'Open the preview in a new tab',\n  loading: 'Loading the preview',\n  error: 'The preview could not load.',\n  retry: 'Try again',\n  frame: (width, percent) => (percent < 100 ? `Frame ${width} px · shown at ${percent} %` : `Frame ${width} px`),\n  announceWidth: (width) => `Frame ${width} px wide`,\n  announceTheme: (theme) => (theme === 'light' ? 'Light theme in the frame' : 'Dark theme in the frame'),\n}\n\n// The default `themeParam`: puts the theme into the URL as `mode=light|dark` and keeps the rest (relative stays relative).\nfunction modeParam(src: string, theme: StageTheme) {\n  const hashAt = src.indexOf('#')\n  const hash = hashAt < 0 ? '' : src.slice(hashAt)\n  const rest = hashAt < 0 ? src : src.slice(0, hashAt)\n  const q = rest.indexOf('?')\n  const params = new URLSearchParams(q < 0 ? '' : rest.slice(q + 1))\n  params.set('mode', theme)\n  return `${q < 0 ? rest : rest.slice(0, q)}?${params}${hash}`\n}\n\nexport type PreviewStageProps = {\n  /** Names the stage and the iframe (\"data-tile demo\"). */\n  title: string\n  /** A page to show in an iframe. */\n  src?: string\n  /** Or your own content. As a function it gets the width and theme; plain nodes get the `dark` class in dark. */\n  children?: ReactNode | ((view: { width: number; theme: StageTheme }) => ReactNode)\n  /** Builds the iframe URL for a theme. By default it sets `mode=light|dark` in the query and keeps the rest. */\n  themeParam?: (src: string, theme: StageTheme) => string\n  /** The widths to switch between, in CSS px. */\n  widths?: number[]\n  /** The width, if you control it. */\n  width?: number\n  /** The first width when you do not. Defaults to the widest. */\n  defaultWidth?: number\n  onWidthChange?: (width: number) => void\n  /** The content's theme, if you control it. */\n  theme?: StageTheme\n  defaultTheme?: StageTheme\n  onThemeChange?: (theme: StageTheme) => void\n  /** Drive the states yourself. Without it, an iframe is loading until it loads, and `children` are ready. */\n  status?: StageStatus\n  /** Called on Reload and Try again (the stage reloads its own iframe or remounts `children` either way). */\n  onReload?: () => void\n  /** Milliseconds an iframe may take, counted once it is in view, before the stage shows the error. */\n  timeout?: number\n  /** A line beside the frame note, e.g. where the demo comes from. */\n  caption?: ReactNode\n  /** The window's height. Defaults to clamp(300px, 48vh, 540px). */\n  height?: number | string\n  /** W and T while focus is in the stage. Off: no keys, no hints, no aria-keyshortcuts. */\n  shortcuts?: boolean\n  /** Every visible and announced string, for other languages. */\n  labels?: Partial<PreviewStageLabels>\n  className?: string\n}\n\nconst KEYCAP =\n  'shadow-[inset_0_-2px_0_color-mix(in_oklab,var(--foreground)_7%,transparent),0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent),0_1px_2px_rgb(0_0_0/0.06)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.09),inset_0_-2px_0_rgb(0_0_0/0.42),0_0_0_1px_rgb(255_255_255/0.1)]'\nconst PIT =\n  'bg-foreground/[0.045] shadow-[inset_0_1px_2px_rgb(0_0_0/0.06),inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_7%,transparent)] dark:bg-black/35 dark:shadow-[inset_0_1px_2px_rgb(0_0_0/0.55),inset_0_0_0_1px_rgb(255_255_255/0.05)]'\nconst HINT =\n  'inline-grid h-5 min-w-5 flex-none place-items-center rounded-[5px] bg-muted px-[5px] font-mono text-[11px] leading-none text-muted-foreground shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_8%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.18),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)] @max-[35rem]:hidden [@media(hover:none)]:hidden'\nconst ROUND =\n  'inline-grid size-11 flex-none place-items-center rounded-full bg-muted text-muted-foreground transition-transform duration-100 ease-out-quint hover:text-foreground active:translate-y-px motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'\nconst DOTS = 'bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_9%,transparent)_1px,transparent_1.3px)] bg-[size:16px_16px]'\n\nfunction isTyping(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false\n  return target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)\n}\n\n// An iframe fires `load` even when its page failed. For a page on this origin the stage can look: no document (the\n// browser's own error page) or an HTTP error status means it failed. A page on another origin cannot be read, so\n// only the timeout can catch it.\n// False on the server and while hydrating, true after. The iframe waits for it: one that loads before React has\n// hydrated would fire its load event with no listener and sit at loading until the timeout.\nconst noSubscribe = () => () => {}\nconst useHydrated = () => useSyncExternalStore(noSubscribe, () => true, () => false)\n\nfunction frameFailed(frame: HTMLIFrameElement) {\n  try {\n    if (new URL(frame.src, location.href).origin !== location.origin) return false\n    const doc = frame.contentDocument\n    if (!doc || doc.URL === 'about:blank') return true\n    const nav = frame.contentWindow?.performance.getEntriesByType('navigation')[0] as (PerformanceEntry & { responseStatus?: number }) | undefined\n    return (nav?.responseStatus ?? 0) >= 400\n  } catch {\n    return false\n  }\n}\n\n/** The readout's digits roll to the new width (transform only). */\nfunction Odometer({ value, still }: { value: number; still: boolean }) {\n  const digits = String(value).split('')\n  return (\n    <span className=\"inline-flex text-[17px] leading-none font-medium tracking-[-0.03em]\">\n      {digits.map((d, i) => (\n        <span key={digits.length - i} className=\"relative inline-block h-[1em] w-[0.62em] overflow-hidden\">\n          <span\n            className={cn('absolute inset-x-0 top-0 flex flex-col', !still && 'transition-transform duration-300 ease-out-quint', 'motion-reduce:transition-none')}\n            style={{ transform: `translateY(${-Number(d)}em)` }}\n          >\n            {Array.from({ length: 10 }, (_, n) => (\n              <span key={n} className=\"block h-[1em] text-center\">\n                {n}\n              </span>\n            ))}\n          </span>\n        </span>\n      ))}\n    </span>\n  )\n}\n\n/** A framed, measured preview with a width switch, light/dark, reload and open in a new tab. */\nexport function PreviewStage({\n  title,\n  src,\n  children,\n  themeParam: buildSrc = modeParam,\n  widths = [320, 768, 1440],\n  width: widthProp,\n  defaultWidth,\n  onWidthChange,\n  theme: themeProp,\n  defaultTheme = 'dark',\n  onThemeChange,\n  status,\n  onReload,\n  timeout = 20000,\n  caption,\n  height,\n  shortcuts = true,\n  labels: labelsProp,\n  className,\n}: PreviewStageProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const reduced = useReducedMotion()\n  const widthLabelId = useId()\n  const box = useRef<HTMLDivElement>(null)\n  const group = useRef<HTMLDivElement>(null)\n  const reloadButton = useRef<HTMLButtonElement>(null)\n  const hydrated = useHydrated()\n\n  const [ownWidth, setOwnWidth] = useState(defaultWidth ?? widths[widths.length - 1])\n  const [ownTheme, setOwnTheme] = useState<StageTheme>(defaultTheme)\n  const width = widthProp ?? ownWidth\n  const theme = themeProp ?? ownTheme\n  const [size, setSize] = useState({ w: 0, h: 0 })\n  const [instant, setInstant] = useState(false)\n  const [reloads, setReloads] = useState(0)\n  const [seen, setSeen] = useState(false)\n  const [said, setSaid] = useState('')\n\n  // The iframe's own state belongs to one URL and one reload; a new one starts out loading.\n  const frameSrc = src ? buildSrc(src, theme) : undefined\n  const loadKey = `${frameSrc ?? ''}#${reloads}`\n  const [loaded, setLoaded] = useState<{ key: string; state: 'ready' | 'error' } | null>(null)\n  const own: StageStatus = !src ? 'ready' : loaded?.key === loadKey ? loaded.state : 'loading'\n  const state = status ?? own\n\n  // Same text twice in a row still gets read: the no-break space makes it a change.\n  const say = (text: string) => setSaid((prev) => (prev === text ? `${text}\\u00a0` : text))\n\n  const still = instant || reduced\n  const index = Math.max(0, widths.indexOf(width))\n  const scale = size.w ? Math.min(1, size.w / width) : 1\n  const open = size.w ? Math.min(1, (width * scale) / size.w) : 1\n  const percent = Math.round(scale * 100)\n\n  useLayoutEffect(() => {\n    const el = box.current\n    if (!el) return\n    const measure = () => setSize((prev) => (prev.w === el.clientWidth && prev.h === el.clientHeight ? prev : { w: el.clientWidth, h: el.clientHeight }))\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  // The timeout counts from when the window is first in view, since a lazy iframe off screen never loads.\n  useEffect(() => {\n    const el = box.current\n    if (!el || seen || typeof IntersectionObserver === 'undefined') return\n    const io = new IntersectionObserver((entries) => {\n      if (entries.some((e) => e.isIntersecting)) setSeen(true)\n    })\n    io.observe(el)\n    return () => io.disconnect()\n  }, [seen])\n\n  useEffect(() => {\n    if (!src || own !== 'loading' || status || !(seen || typeof IntersectionObserver === 'undefined')) return\n    const t = window.setTimeout(() => setLoaded({ key: loadKey, state: 'error' }), timeout)\n    return () => window.clearTimeout(t)\n  }, [src, own, seen, status, loadKey, timeout])\n\n  const setWidth = (next: number, fromKey: boolean) => {\n    setInstant(fromKey)\n    if (next === width) return\n    if (widthProp === undefined) setOwnWidth(next)\n    onWidthChange?.(next)\n    say(labels.announceWidth(next))\n  }\n  const stepWidth = (dir: number, fromKey: boolean, focus = false) => {\n    const next = (index + dir + widths.length) % widths.length\n    setWidth(widths[next], fromKey)\n    if (focus) group.current?.querySelectorAll<HTMLElement>('[role=radio]')[next]?.focus()\n  }\n  const flipTheme = (fromKey: boolean) => {\n    const next: StageTheme = theme === 'light' ? 'dark' : 'light'\n    setInstant(fromKey)\n    if (themeProp === undefined) setOwnTheme(next)\n    onThemeChange?.(next)\n    say(labels.announceTheme(next))\n  }\n  const reload = () => {\n    setReloads((n) => n + 1)\n    say(labels.loading)\n    onReload?.()\n  }\n  const retry = () => {\n    reload()\n    reloadButton.current?.focus({ preventScroll: true })\n  }\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!shortcuts || e.defaultPrevented || e.repeat || e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return\n    const k = e.key.toLowerCase()\n    if (k === 'w') {\n      e.preventDefault()\n      stepWidth(e.shiftKey ? -1 : 1, true)\n    } else if (k === 't' && !e.shiftKey) {\n      e.preventDefault()\n      flipTheme(true)\n    }\n  }\n\n  const onRadioKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]\n    if (step) {\n      e.preventDefault()\n      stepWidth(step, true, true)\n    } else if (e.key === 'Home' || e.key === 'End') {\n      e.preventDefault()\n      const to = e.key === 'Home' ? 0 : widths.length - 1\n      setWidth(widths[to], true)\n      group.current?.querySelectorAll<HTMLElement>('[role=radio]')[to]?.focus()\n    }\n  }\n\n  const content = typeof children === 'function' ? children({ width, theme }) : children\n  const light = theme === 'light'\n\n  return (\n    <div role=\"group\" aria-label={title} onKeyDown={onKeyDown} className={cn('@container min-w-0', className)}>\n      <style href=\"manniche-preview-stage\" precedence=\"default\">{`\n        @keyframes manniche-stage-appear { to { opacity: 1 } }\n        @keyframes manniche-stage-indet { from { transform: translateX(-100%) } to { transform: translateX(340%) } }\n      `}</style>\n\n      <div className=\"mb-3 flex flex-wrap items-center justify-between gap-x-5 gap-y-3\">\n        <div className=\"flex min-w-0 items-center gap-2.5 @max-[35rem]:flex-1\">\n          <span id={widthLabelId} className=\"text-[12.5px] leading-none font-medium text-muted-foreground @max-[35rem]:hidden\">\n            {labels.width}\n          </span>\n          <div\n            ref={group}\n            role=\"radiogroup\"\n            aria-labelledby={widthLabelId}\n            aria-keyshortcuts={shortcuts ? 'W' : undefined}\n            onKeyDown={onRadioKey}\n            className={cn('relative isolate grid min-w-[216px] auto-cols-fr grid-flow-col rounded-[14px] p-1 @max-[35rem]:min-w-0 @max-[35rem]:flex-1', PIT)}\n          >\n            <span\n              aria-hidden\n              className={cn(\n                'absolute inset-y-1 left-1 -z-10 rounded-[10px] bg-card dark:bg-[color-mix(in_oklab,var(--card)_80%,white_6%)]',\n                KEYCAP,\n                !still && 'transition-transform duration-[280ms] ease-out-quint',\n                'motion-reduce:transition-none',\n              )}\n              style={{ width: `calc((100% - 8px) / ${widths.length})`, transform: `translateX(${index * 100}%)` }}\n            >\n              <i className=\"absolute top-[7px] right-2 size-[5px] rounded-full bg-primary\" />\n            </span>\n            {widths.map((w, i) => (\n              <button\n                key={w}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={i === index}\n                tabIndex={i === index ? 0 : -1}\n                onClick={(e: MouseEvent) => setWidth(w, e.detail === 0)}\n                className={cn(\n                  'relative flex min-h-11 items-center justify-center rounded-[10px] px-2.5 font-mono text-[13px] leading-none font-medium tabular-nums select-none',\n                  'transition-transform duration-100 ease-out-quint active:translate-y-px motion-reduce:transition-none',\n                  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n                  i === index ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',\n                )}\n              >\n                {w}\n                <span className=\"sr-only\"> {labels.px}</span>\n              </button>\n            ))}\n          </div>\n          {shortcuts && (\n            <span aria-hidden className={HINT}>\n              W\n            </span>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-2 @max-[35rem]:w-full @max-[35rem]:justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <button\n              type=\"button\"\n              role=\"switch\"\n              aria-checked={light}\n              aria-label={`${labels.dark} ${labels.light}: ${labels.themeSwitch}`}\n              aria-keyshortcuts={shortcuts ? 'T' : undefined}\n              onClick={(e) => flipTheme(e.detail === 0)}\n              className=\"group/tog inline-flex min-h-11 items-center gap-2.5 rounded-full px-1 text-[13px] leading-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n            >\n              <span className={light ? 'text-muted-foreground' : 'text-foreground'}>{labels.dark}</span>\n              <span className={cn('relative h-7 w-[52px] rounded-full', PIT)}>\n                <span\n                  className={cn(\n                    'absolute top-[3px] left-[3px] size-[22px] rounded-full',\n                    light ? 'translate-x-6 bg-foreground shadow-[inset_0_-2px_0_rgb(0_0_0/0.25)]' : cn('bg-card', KEYCAP),\n                    !still && 'transition-transform duration-[260ms] ease-out-quint',\n                    'group-active/tog:scale-[0.96] motion-reduce:transition-none',\n                  )}\n                />\n              </span>\n              <span className={light ? 'text-foreground' : 'text-muted-foreground'}>{labels.light}</span>\n            </button>\n            {shortcuts && (\n              <span aria-hidden className={HINT}>\n                T\n              </span>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <button ref={reloadButton} type=\"button\" aria-label={labels.reload} onClick={reload} className={ROUND}>\n              <RotateCw className=\"size-4\" aria-hidden />\n            </button>\n            {frameSrc && (\n              <a href={frameSrc} target=\"_blank\" rel=\"noopener noreferrer\" aria-label={labels.open} className={ROUND}>\n                <ArrowUpRight className=\"size-4\" aria-hidden />\n              </a>\n            )}\n          </div>\n        </div>\n      </div>\n\n      <div className={cn('overflow-clip rounded-[calc(var(--radius)*2+2px)] p-3', PIT)}>\n        <div aria-hidden className=\"relative mb-2 h-11\">\n          <div\n            className={cn(\n              'absolute inset-x-0 bottom-0 h-3.5 bg-no-repeat [background-position:0_100%,0_100%] [background-size:100%_6px,100%_14px]',\n              '[background-image:repeating-linear-gradient(90deg,color-mix(in_oklab,var(--foreground)_28%,transparent)_0_1px,transparent_1px_calc(100%/36)),repeating-linear-gradient(90deg,color-mix(in_oklab,var(--foreground)_50%,transparent)_0_1px,transparent_1px_calc(100%/9))]',\n              'after:absolute after:right-0 after:bottom-0 after:h-3.5 after:w-px after:bg-foreground/50',\n            )}\n          />\n          {[-1, 1].map((side) => (\n            <div\n              key={side}\n              className={cn('pointer-events-none absolute inset-0', !still && 'transition-transform duration-300 ease-out-quint', 'motion-reduce:transition-none')}\n              style={{ transform: `translateX(${side * open * 50}%)` }}\n            >\n              <span className=\"absolute bottom-0 left-1/2 -ml-px h-[26px] w-0.5 rounded-[1px] bg-foreground before:absolute before:-top-px before:-left-1 before:h-0.5 before:w-2.5 before:rounded-[1px] before:bg-foreground\" />\n            </div>\n          ))}\n          <div className={cn('absolute top-0.5 left-1/2 flex -translate-x-1/2 items-baseline gap-1.5 rounded-[8px] px-2.5 py-1 font-mono', PIT, 'dark:bg-black/50')}>\n            <Odometer value={width} still={still} />\n            <span className=\"text-[11.5px] leading-none font-medium text-muted-foreground\">{labels.px}</span>\n          </div>\n        </div>\n\n        <div\n          ref={box}\n          data-state={state}\n          className={cn(\n            'relative isolate overflow-hidden rounded-[14px] bg-background',\n            'after:pointer-events-none after:absolute after:inset-0 after:z-[3] after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]',\n            'has-[iframe:focus-visible]:outline-2 has-[iframe:focus-visible]:outline-offset-2 has-[iframe:focus-visible]:outline-ring',\n            height === undefined && 'h-[clamp(300px,48vh,540px)]',\n          )}\n          style={height === undefined ? undefined : { height }}\n        >\n          <div\n            className=\"absolute top-0 left-1/2 origin-top\"\n            style={{ width, height: size.h ? size.h / scale : '100%', transform: `translateX(-50%) scale(${scale})` }}\n          >\n            {frameSrc ? (\n              hydrated && (\n              <iframe\n                key={loadKey}\n                src={frameSrc}\n                title={title}\n                loading=\"lazy\"\n                onLoad={(e) => setLoaded({ key: loadKey, state: frameFailed(e.currentTarget) ? 'error' : 'ready' })}\n                onError={() => setLoaded({ key: loadKey, state: 'error' })}\n                className={cn(\n                  'block size-full border-0 transition-opacity duration-200 ease-out-quint motion-reduce:transition-none',\n                  state === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0',\n                )}\n              />\n              )\n            ) : (\n              <div\n                key={reloads}\n                data-theme={theme}\n                style={{ colorScheme: theme }}\n                className={cn(\n                  'size-full overflow-hidden bg-background text-foreground transition-opacity duration-200 ease-out-quint motion-reduce:transition-none',\n                  typeof children !== 'function' && theme === 'dark' && 'dark',\n                  state === 'ready' ? 'opacity-100' : 'opacity-0',\n                )}\n              >\n                {content}\n              </div>\n            )}\n          </div>\n\n          {state === 'loading' && (\n            <div\n              className={cn(\n                'absolute inset-0 z-[1] grid place-content-center justify-items-center gap-3.5 p-6 text-center text-sm text-muted-foreground opacity-0',\n                DOTS,\n                '[animation:manniche-stage-appear_150ms_var(--ease-out-quint)_300ms_forwards] motion-reduce:[animation-duration:0s]',\n              )}\n            >\n              <i aria-hidden className=\"relative block h-0.5 w-40 overflow-hidden rounded-full bg-foreground/12\">\n                <i className=\"absolute inset-y-0 left-0 w-[30%] bg-muted-foreground [animation:manniche-stage-indet_1.2s_var(--ease-out-quint)_infinite] motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\" />\n              </i>\n              <span>{labels.loading}</span>\n            </div>\n          )}\n\n          {state === 'error' && (\n            <div className={cn('absolute inset-0 z-[1] grid place-content-center justify-items-center gap-3.5 p-6 text-center text-sm text-muted-foreground', DOTS)}>\n              <p>{labels.error}</p>\n              <button\n                type=\"button\"\n                onClick={retry}\n                className={cn(\n                  'inline-flex h-11 items-center gap-2 rounded-full bg-card px-4 text-sm text-foreground',\n                  KEYCAP,\n                  'transition-transform duration-100 ease-out-quint hover:bg-muted active:translate-y-px motion-reduce:transition-none',\n                  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n                )}\n              >\n                <RotateCw className=\"size-3.5\" aria-hidden />\n                {labels.retry}\n              </button>\n            </div>\n          )}\n\n          {[-1, 1].map((side) => (\n            <div\n              key={side}\n              aria-hidden\n              className={cn(\n                'absolute inset-y-0 z-[2] w-1/2 bg-muted dark:bg-[color-mix(in_oklab,var(--background)_70%,black)]',\n                'bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_6%,transparent)_0_1px,transparent_1px_8px)]',\n                'after:absolute after:top-1/2 after:-mt-[17px] after:h-[34px] after:w-1 after:rounded-sm after:bg-foreground/25',\n                side < 0\n                  ? 'left-0 shadow-[inset_-1px_0_0_color-mix(in_oklab,var(--foreground)_22%,transparent)] after:right-1.5'\n                  : 'right-0 shadow-[inset_1px_0_0_color-mix(in_oklab,var(--foreground)_22%,transparent)] after:left-1.5',\n                !still && 'transition-transform duration-300 ease-out-quint',\n                'motion-reduce:transition-none',\n              )}\n              style={{ transform: `translateX(${side * open * 100}%)` }}\n            />\n          ))}\n        </div>\n\n        <p className=\"mx-1 mt-3 mb-0.5 flex justify-between gap-4 text-[13px] leading-normal text-muted-foreground @max-[35rem]:flex-col @max-[35rem]:gap-1\">\n          <span className=\"tabular-nums\">{labels.frame(width, percent)}</span>\n          {caption && <span className=\"text-right @max-[35rem]:text-left\">{caption}</span>}\n        </p>\n      </div>\n\n      <p role=\"status\" className=\"sr-only\">\n        {state === 'error' ? labels.error : said}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "meta": {
    "tier": "free",
    "added": "2026-10-07",
    "usedIn": [],
    "props": [
      {
        "name": "title",
        "type": "string",
        "default": null,
        "required": true,
        "description": "Names the stage and the iframe (\"data-tile demo\")."
      },
      {
        "name": "src",
        "type": "string",
        "default": null,
        "required": false,
        "description": "A page to show in an iframe."
      },
      {
        "name": "children",
        "type": "ReactNode | ((view: { width: number; theme: StageTheme }) => ReactNode)",
        "default": null,
        "required": false,
        "description": "Or your own content. As a function it gets the width and theme; plain nodes get the `dark` class in dark."
      },
      {
        "name": "themeParam",
        "type": "(src: string, theme: StageTheme) => string",
        "default": "modeParam",
        "required": false,
        "description": "Builds the iframe URL for a theme. By default it sets `mode=light|dark` in the query and keeps the rest."
      },
      {
        "name": "widths",
        "type": "number[]",
        "default": "[320, 768, 1440]",
        "required": false,
        "description": "The widths to switch between, in CSS px."
      },
      {
        "name": "width",
        "type": "number",
        "default": null,
        "required": false,
        "description": "The width, if you control it."
      },
      {
        "name": "defaultWidth",
        "type": "number",
        "default": null,
        "required": false,
        "description": "The first width when you do not. Defaults to the widest."
      },
      {
        "name": "onWidthChange",
        "type": "(width: number) => void",
        "default": null,
        "required": false,
        "description": null
      },
      {
        "name": "theme",
        "type": "StageTheme",
        "default": null,
        "required": false,
        "description": "The content's theme, if you control it."
      },
      {
        "name": "defaultTheme",
        "type": "StageTheme",
        "default": "'dark'",
        "required": false,
        "description": null
      },
      {
        "name": "onThemeChange",
        "type": "(theme: StageTheme) => void",
        "default": null,
        "required": false,
        "description": null
      },
      {
        "name": "status",
        "type": "StageStatus",
        "default": null,
        "required": false,
        "description": "Drive the states yourself. Without it, an iframe is loading until it loads, and `children` are ready."
      },
      {
        "name": "onReload",
        "type": "() => void",
        "default": null,
        "required": false,
        "description": "Called on Reload and Try again (the stage reloads its own iframe or remounts `children` either way)."
      },
      {
        "name": "timeout",
        "type": "number",
        "default": "20000",
        "required": false,
        "description": "Milliseconds an iframe may take, counted once it is in view, before the stage shows the error."
      },
      {
        "name": "caption",
        "type": "ReactNode",
        "default": null,
        "required": false,
        "description": "A line beside the frame note, e.g. where the demo comes from."
      },
      {
        "name": "height",
        "type": "number | string",
        "default": null,
        "required": false,
        "description": "The window's height. Defaults to clamp(300px, 48vh, 540px)."
      },
      {
        "name": "shortcuts",
        "type": "boolean",
        "default": "true",
        "required": false,
        "description": "W and T while focus is in the stage. Off: no keys, no hints, no aria-keyshortcuts."
      },
      {
        "name": "labels",
        "type": "Partial<PreviewStageLabels>",
        "default": null,
        "required": false,
        "description": "Every visible and announced string, for other languages."
      },
      {
        "name": "className",
        "type": "string",
        "default": null,
        "required": false,
        "description": null
      }
    ],
    "a11y": [
      {
        "topic": "keyboard",
        "text": "The width switch is a radio group with one Tab stop: arrow keys, Home and End move it. The theme control is a switch. W and T change width and theme only while focus is inside the stage, and shortcuts={false} turns them off. Try again moves focus to Reload.",
        "keys": [
          "ArrowLeft",
          "ArrowRight",
          "Home",
          "End",
          "W",
          "T"
        ]
      },
      {
        "topic": "screen-reader",
        "text": "The stage is a group named by title. A polite status announces width, theme, loading and error. Icon buttons have names; the ruler and shutters are aria-hidden."
      },
      {
        "topic": "motion",
        "text": "The shutters and scale use transform; under reduced motion they jump to the end state. The loader shows only after 300 ms."
      }
    ]
  },
  "categories": [
    "surfaces"
  ],
  "type": "registry:component"
}