{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "view-transition",
  "type": "registry:style",
  "title": "viewTransition",
  "description": "Interrupt-safe driver for the browser View Transitions API: page transitions, shared-element morphs, and a reduced-motion fallback for both.",
  "dependencies": [],
  "registryDependencies": [
    "https://control-ui.dev/r/core.json"
  ],
  "files": [
    {
      "path": "src/registry/hooks/use-morph-transition.ts",
      "target": "@components/control-ui/hooks/use-morph-transition.ts",
      "type": "registry:hook",
      "content": "\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport { useId } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { startMorphViewTransition } from \"@/components/control-ui/extensions/view-transition\";\n\n// trigger wears shared name while closed, surface while open. That alternation is whole contract:\n// View Transitions API aborts outright when two live elements claim one name at capture, which is exactly what a\n// portalled popup mounting over still-present trigger would do. Matching is by name, not tree position, so it\n// crosses portals and top layer. unnamed trigger stays uncaptured and keeps painting under overlay —\n// hide it with data-[popup-open]:opacity-0 when it should BECOME surface instead.\nexport type UseMorphTransitionOptions = {\n  /** Open state of surface — decides which end of pair currently owns shared name. */\n  open: boolean;\n  /** Override generated name; only needed when trigger and surface live in separate React trees. */\n  name?: string;\n};\n\nexport type MorphAnchorProps = {\n  className: string | undefined;\n  style: CSSProperties | undefined;\n};\n\nexport type UseMorphTransitionResult = {\n  /** Wraps state change that opens or closes surface. Falls through untouched when morphing is off. */\n  morph: (update: () => void) => void;\n  triggerProps: MorphAnchorProps;\n  surfaceProps: MorphAnchorProps;\n};\n\ntype MorphStyle = CSSProperties & Record<\"--morph-name\", string>;\n\nconst MORPH_CLASS = \"morph-surface\";\nconst INERT_ANCHOR: MorphAnchorProps = { className: undefined, style: undefined };\n\n// view-transition-name takes custom-ident; useId ships delimiters (:r1:, «r1») that are not valid idents.\nfunction toCustomIdent(id: string) {\n  return `aui-morph-${id.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n}\n\nexport function useMorphTransition({ open, name }: UseMorphTransitionOptions): UseMorphTransitionResult {\n  const generatedId = useId();\n  const morphName = name ?? toCustomIdent(generatedId);\n\n  const style: MorphStyle = { \"--morph-name\": morphName };\n  const anchor: MorphAnchorProps = { className: MORPH_CLASS, style };\n\n  // browser snapshots before `update` and again right after it returns, so React's async commit would capture same frame twice\n  function morph(update: () => void) {\n    startMorphViewTransition(() => flushSync(update));\n  }\n\n  return {\n    morph,\n    triggerProps: open ? INERT_ANCHOR : anchor,\n    surfaceProps: open ? anchor : INERT_ANCHOR,\n  };\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/extensions/view-transition.css",
      "target": "@components/control-ui/styles/view-transition.css",
      "type": "registry:file",
      "content": "/* view-page-rise — page transition preset via browser View Transitions API (extensions/view-transition.ts), NOT React's experimental <ViewTransition>.\n * Page body (content+ToC) carries docs-page name so only it animates: old rises out (down+blur/fade), new enters from above. */\n:root {\n  --view-transition-page-distance: calc(var(--spacing) * 4);\n  --view-transition-page-blur: 20px;\n}\n\n/* Opt root OUT of transition (override UA implicit root name): a participating element paints into the overlay and stops being hit-testable — root captured meant the whole doc went inert, every click landed on <html>.\n * Un-naming root keeps shell (sidebar/header/theme drawer) live: swaps real-time, stays clickable mid-transition.\n * Sidebar's track-highlight pill slides itself instead of being frozen/interpolated by the snapshot; only the named page body is captured. */\n:root {\n  view-transition-name: none;\n}\n\n/* Transition overlay covers viewport during animation; pointer-events:none keeps live (non-participating) DOM underneath clickable.\n * Clicks on captured page body handled by extensions/view-transition.ts (skips running transition on first pointerdown, re-targets swallowed click). */\n::view-transition {\n  pointer-events: none;\n}\n\n.view-page {\n  view-transition-name: docs-page;\n}\n\n/* Keep exchange within one --duration-slow: navigation is workflow, not showcase — longer reads as lag, widens the non-interactive snapshot window. */\n::view-transition-group(docs-page) {\n  animation-duration: var(--duration-slow);\n  animation-timing-function: var(--ease-emphasized);\n}\n\n::view-transition-old(docs-page) {\n  animation: aui-view-page-rise-old var(--duration-fast) var(--ease-standard) both;\n}\n\n::view-transition-new(docs-page) {\n  animation: aui-view-page-rise-new var(--duration-slow) var(--ease-emphasized) both;\n}\n\n/* Fallback for engines ignoring root un-naming: shell swaps instantly inside snapshot instead of cross-fading; dead rules when root opt-out applies.\n * Sidebar's active pill deliberately has NO view-transition-name — shell live, track-highlight's own slide handles it.\n * Snapshot-interpolated pill would double-animate against that slide, visibly jump when transition skipped mid-flight. */\n::view-transition-old(root),\n::view-transition-new(root) {\n  animation: none;\n}\n\n@keyframes aui-view-page-rise-old {\n  from {\n    opacity: 1;\n    filter: blur(0);\n    translate: 0 0;\n  }\n  to {\n    opacity: 0;\n    filter: blur(var(--view-transition-page-blur));\n    translate: 0 var(--view-transition-page-distance);\n  }\n}\n\n@keyframes aui-view-page-rise-new {\n  from {\n    opacity: 0;\n    filter: blur(var(--view-transition-page-blur));\n    translate: 0 calc(var(--view-transition-page-distance) * -1);\n  }\n  to {\n    opacity: 1;\n    filter: blur(0);\n    translate: 0 0;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  ::view-transition-old(docs-page),\n  ::view-transition-new(docs-page) {\n    animation: none;\n  }\n}\n\n:root[data-motion=\"reduced\"]::view-transition-old(docs-page),\n:root[data-motion=\"reduced\"]::view-transition-new(docs-page) {\n  animation: none;\n}\n\n/* morph-surface — shared-element morph preset for startMorphViewTransition() (extensions/view-transition.ts).\n * Trigger and surface carry the same --morph-name at opposite ends of one state change; the browser interpolates\n * the box between the two rects. CSS-native layoutId: no FLIP measurement, no animation runtime, no portal juggling.\n * The name must be held by exactly ONE live element per capture — useMorphTransition gates it on open state. */\n.morph-surface {\n  view-transition-name: var(--morph-name);\n  view-transition-class: aui-morph;\n}\n\n/* A morph is element-scoped: the page body must not be captured alongside it or view-page-rise replays behind\n * the morphing box. Driver sets the attribute before the old snapshot and clears it once the transition settles. */\n:root[data-view-transition=\"morph\"] .view-page {\n  view-transition-name: none;\n}\n\n/* view-transition-class groups every morph pair under one rule instead of a per-name rule the CSS cannot know.\n * Engines without it fall back to the UA default group animation — still a morph, just untuned; nothing to guard. */\n::view-transition-group(.aui-morph) {\n  animation-duration: var(--duration-slow);\n  animation-timing-function: var(--ease-emphasized);\n}\n\n/* Snapshots render at natural size, centred in the interpolating box, instead of stretching between two very\n * different aspect ratios — content stays crisp and the box does the travelling (container-transform read). */\n::view-transition-old(.aui-morph),\n::view-transition-new(.aui-morph) {\n  width: 100%;\n  height: 100%;\n  object-fit: none;\n  object-position: center;\n}\n\n/* Old leaves early, new fades across the full travel: content settles as the box lands rather than before it. */\n::view-transition-old(.aui-morph) {\n  animation: aui-morph-fade-out var(--duration-fast) var(--ease-standard) both;\n}\n\n::view-transition-new(.aui-morph) {\n  animation: aui-morph-fade-in var(--duration-slow) var(--ease-emphasized) both;\n}\n\n@keyframes aui-morph-fade-out {\n  from {\n    opacity: 1;\n  }\n  to {\n    opacity: 0;\n  }\n}\n\n@keyframes aui-morph-fade-in {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n/* Driver already skips the transition under either signal; these keep a hand-rolled startViewTransition() honest. */\n@media (prefers-reduced-motion: reduce) {\n  ::view-transition-group(.aui-morph),\n  ::view-transition-old(.aui-morph),\n  ::view-transition-new(.aui-morph) {\n    animation: none;\n  }\n}\n\n:root[data-motion=\"reduced\"]::view-transition-group(.aui-morph),\n:root[data-motion=\"reduced\"]::view-transition-old(.aui-morph),\n:root[data-motion=\"reduced\"]::view-transition-new(.aui-morph) {\n  animation: none;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/extensions/view-transition.ts",
      "target": "@components/control-ui/extensions/view-transition.ts",
      "type": "registry:component",
      "content": "// Two things naive startViewTransition() gets wrong in router-driven app, fixed here: completion stays pending until\n// finishPageViewTransition() so browser snapshots NEW page, and transition started mid-flight skips running one.\nconst FINISH_TIMEOUT_MS = 500;\n\n// lets CSS preset un-name page-level participants while element morph runs\nconst MORPH_ATTRIBUTE = \"data-view-transition\";\n\nlet finishTransition: (() => void) | null = null;\nlet activeTransition: ViewTransition | null = null;\nlet activeMorph: ViewTransition | null = null;\n\nexport function supportsViewTransition() {\n  return typeof document !== \"undefined\" && typeof document.startViewTransition === \"function\";\n}\n\nexport function motionReduced() {\n  return (\n    document.documentElement.getAttribute(\"data-motion\") === \"reduced\" || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\n/** Call once new view is on screen. */\nexport function finishPageViewTransition() {\n  finishTransition?.();\n  finishTransition = null;\n}\n\nexport function startPageViewTransition(update: () => void, { finishTimeout = FINISH_TIMEOUT_MS }: { finishTimeout?: number } = {}) {\n  if (!supportsViewTransition() || motionReduced()) {\n    update();\n    return;\n  }\n\n  // jump any mid-flight transition to its end state, so this navigation snapshots live DOM instead of waiting\n  activeTransition?.skipTransition();\n  finishPageViewTransition();\n\n  const transition = document.startViewTransition(\n    () =>\n      new Promise<void>((resolve) => {\n        finishTransition = resolve;\n        update();\n        // blocked or cancelled navigation would otherwise leave page frozen under snapshot\n        window.setTimeout(() => {\n          if (finishTransition === resolve) {\n            finishTransition = null;\n            resolve();\n          }\n        }, finishTimeout);\n      }),\n  );\n\n  activeTransition = transition;\n\n  // Captured elements paint into overlay and stop being hit-testable, so press during transition would be eaten.\n  // Skipping to end brings live DOM back, but that press's click already targeted root — re-target it to whatever now sits under pointer.\n  const interrupt = () => {\n    transition.skipTransition();\n    window.addEventListener(\n      \"click\",\n      (click) => {\n        if (click.target !== document.documentElement && click.target !== document.body) return;\n        const actionable = document.elementFromPoint(click.clientX, click.clientY)?.closest(\"a, button\");\n        if (actionable instanceof HTMLElement) actionable.click();\n      },\n      { capture: true, once: true },\n    );\n  };\n  window.addEventListener(\"pointerdown\", interrupt, { capture: true, once: true });\n\n  // ready/finished reject on mid-flight skip — expected, not error\n  transition.ready.catch(() => {});\n  transition.finished\n    .catch(() => {})\n    .finally(() => {\n      window.removeEventListener(\"pointerdown\", interrupt, { capture: true });\n      if (activeTransition === transition) activeTransition = null;\n    });\n}\n\n// Trigger and surface carry SAME view-transition-name at opposite ends of one state change and browser\n// interpolates box between them. `update` must apply its DOM change synchronously — React callers wrap it in flushSync.\n// Enforced here: page-level names are un-named for transition's lifetime (they would replay page preset behind the\n// morph), and second morph skips first rather than fighting it over overlay. Uniqueness of shared name stays\n// caller's job — two live elements holding one name abort transition outright.\nexport function startMorphViewTransition(update: () => void) {\n  if (!supportsViewTransition() || motionReduced()) {\n    update();\n    return;\n  }\n\n  activeMorph?.skipTransition();\n  document.documentElement.setAttribute(MORPH_ATTRIBUTE, \"morph\");\n\n  const transition = document.startViewTransition(update);\n  activeMorph = transition;\n\n  // ready/finished reject when skipped mid-flight — expected interruption, not error.\n  transition.ready.catch(() => {});\n  transition.finished\n    .catch(() => {})\n    .finally(() => {\n      if (activeMorph !== transition) return;\n      activeMorph = null;\n      document.documentElement.removeAttribute(MORPH_ATTRIBUTE);\n    });\n}\n"
    }
  ],
  "css": {
    "@import \"../components/control-ui/styles/view-transition.css\"": {}
  },
  "meta": {}
}
