viewTransition
Interrupt-safe driver for the browser View Transitions API: page transitions, shared-element morphs, and a reduced-motion fallback for both.
Root-mountedMounted once above its targets and attached through the emitted anatomy — no component imports it, and removing the item removes every byte. Not installed means no import, no listener, no cost.
First install and activate one skin. Core deliberately contains no visual token defaults.
Installs to components/control-ui/extensions/view-transition.ts. Extensions are optional items layered on the library — no component bundle carries them.
npx shadcn@latest add https://control-ui.dev/r/view-transition.jsonActivation
Wrap your router navigation in startPageViewTransition and resolve it once the new route has rendered. For element morphs, useMorphTransition hands out one shared name and two prop bags — the trigger wears it while closed, the surface while open, so a portalled popup can grow out of the button that opened it. Both CSS presets ship with the item.
// page — on navigation (framework-agnostic, router glue stays in your app)startPageViewTransition(() => router.push(href));finishPageViewTransition(); // once the new view is on screen (e.g. a pathname effect)// element morph — CSS-native layoutId; works across portal and top layerconst { morph, triggerProps, surfaceProps } = useMorphTransition({ open });<Dialog open={open} onOpenChange={(next) => morph(() => setOpen(next))}> {/* drop data-[popup-open]:opacity-0 to keep trigger on page while dialog is open */} <DialogTrigger {...triggerProps} className={cn(triggerProps.className, "data-[popup-open]:opacity-0")} render={<Button />} > Open </DialogTrigger> <DialogContent {...surfaceProps}>…</DialogContent></Dialog>Source
Owned source and private support files
// Two things naive startViewTransition() gets wrong in router-driven app, fixed here: completion stays pending until// finishPageViewTransition() so browser snapshots NEW page, and transition started mid-flight skips running one.const FINISH_TIMEOUT_MS = 500;// lets CSS preset un-name page-level participants while element morph runsconst MORPH_ATTRIBUTE = "data-view-transition";let finishTransition: (() => void) | null = null;let activeTransition: ViewTransition | null = null;let activeMorph: ViewTransition | null = null;export function supportsViewTransition() { return typeof document !== "undefined" && typeof document.startViewTransition === "function";}export function motionReduced() { return ( document.documentElement.getAttribute("data-motion") === "reduced" || window.matchMedia("(prefers-reduced-motion: reduce)").matches );}/** Call once new view is on screen. */export function finishPageViewTransition() { finishTransition?.(); finishTransition = null;}export function startPageViewTransition(update: () => void, { finishTimeout = FINISH_TIMEOUT_MS }: { finishTimeout?: number } = {}) { if (!supportsViewTransition() || motionReduced()) { update(); return; } // jump any mid-flight transition to its end state, so this navigation snapshots live DOM instead of waiting activeTransition?.skipTransition(); finishPageViewTransition(); const transition = document.startViewTransition( () => new Promise<void>((resolve) => { finishTransition = resolve; update(); // blocked or cancelled navigation would otherwise leave page frozen under snapshot window.setTimeout(() => { if (finishTransition === resolve) { finishTransition = null; resolve(); } }, finishTimeout); }), ); activeTransition = transition; // Captured elements paint into overlay and stop being hit-testable, so press during transition would be eaten. // Skipping to end brings live DOM back, but that press's click already targeted root — re-target it to whatever now sits under pointer. const interrupt = () => { transition.skipTransition(); window.addEventListener( "click", (click) => { if (click.target !== document.documentElement && click.target !== document.body) return; const actionable = document.elementFromPoint(click.clientX, click.clientY)?.closest("a, button"); if (actionable instanceof HTMLElement) actionable.click(); }, { capture: true, once: true }, ); }; window.addEventListener("pointerdown", interrupt, { capture: true, once: true }); // ready/finished reject on mid-flight skip — expected, not error transition.ready.catch(() => {}); transition.finished .catch(() => {}) .finally(() => { window.removeEventListener("pointerdown", interrupt, { capture: true }); if (activeTransition === transition) activeTransition = null; });}// Trigger and surface carry SAME view-transition-name at opposite ends of one state change and browser// interpolates box between them. `update` must apply its DOM change synchronously — React callers wrap it in flushSync.// Enforced here: page-level names are un-named for transition's lifetime (they would replay page preset behind the// morph), and second morph skips first rather than fighting it over overlay. Uniqueness of shared name stays// caller's job — two live elements holding one name abort transition outright.export function startMorphViewTransition(update: () => void) { if (!supportsViewTransition() || motionReduced()) { update(); return; } activeMorph?.skipTransition(); document.documentElement.setAttribute(MORPH_ATTRIBUTE, "morph"); const transition = document.startViewTransition(update); activeMorph = transition; // ready/finished reject when skipped mid-flight — expected interruption, not error. transition.ready.catch(() => {}); transition.finished .catch(() => {}) .finally(() => { if (activeMorph !== transition) return; activeMorph = null; document.documentElement.removeAttribute(MORPH_ATTRIBUTE); });}