{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reveal","type":"registry:ui","title":"Reveal","description":"Content-agnostic entrance animation triggered by scroll, mount, or a manual switch, with six directional/scale presets and an arbitrary-length JS-computed stagger for direct children","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/reveal/Reveal.tsx","content":"\"use client\";\n\nimport { createElement, forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useInView } from \"../../internals/motion/in-view\";\nimport { staggerDelay } from \"../../internals/motion/stagger\";\nimport { STAGGER_CAPS } from \"../../internals/motion/tokens\";\nimport type { RevealPresetName, StaggerFrom } from \"../../internals/motion/types\";\nimport \"./reveal.css\";\n\nexport interface RevealProps extends Omit<HTMLAttributes<HTMLElement>, \"children\"> {\n\t/** Which of the six directional/scale looks to animate with. */\n\tpreset?: RevealPresetName;\n\t/** What starts the reveal. `\"view\"` watches the viewport, `\"mount\"` fires on the next frame, `\"manual\"` follows the `active` prop. */\n\ttrigger?: \"view\" | \"mount\" | \"manual\";\n\t/** Read only when `trigger=\"manual\"` — `true` reveals, `false` re-arms. */\n\tactive?: boolean;\n\t/** Disconnects the observer after the first reveal. `false` re-arms (and re-hides, unless reduced motion) every time the node leaves the viewport. Only meaningful for `trigger=\"view\"`. */\n\tonce?: boolean;\n\t/** IntersectionObserver threshold. Only meaningful for `trigger=\"view\"`. */\n\tthreshold?: number;\n\t/** IntersectionObserver rootMargin. Only meaningful for `trigger=\"view\"`. */\n\trootMargin?: string;\n\t/** Entrance duration in ms. */\n\tduration?: number;\n\t/** Delay before the entrance starts, in ms. */\n\tdelay?: number;\n\t/** CSS easing for the entrance. */\n\teasing?: string;\n\t/** Travel distance in px for the four directional presets — ignored by `scale`. */\n\tdistance?: number;\n\t/** ms per stagger step. `0` (default) animates the root itself; any positive value switches to animating direct element children instead, each offset by its own computed delay. */\n\tstagger?: number;\n\t/** Where the stagger counts distance from. Only meaningful when `stagger > 0`. */\n\tfrom?: StaggerFrom;\n\t/** How the very first server-rendered paint looks. `\"hidden\"` (the default) starts at `data-state=\"armed\"`, so the content is already hidden before hydration and there is no flash — at the cost of staying hidden on a page that never hydrates (the CSS is gated on `(scripting: enabled)`, which reflects the browser, not this page). `\"visible\"` starts at the pre-mount `\"idle\"` state, which paints fully visible; the mount effect then flips it to `\"armed\"` (an instant hide, no fade-out) and the reveal plays from there. Use it for content that must be readable with JS off or on a `csr = false` route, and accept a one-frame flash. See the README's SSR section. */\n\tinitial?: \"hidden\" | \"visible\";\n\t/** The element tag Reveal renders as. */\n\tas?: keyof HTMLElementTagNameMap;\n\t/** Fires once per reveal, the moment the state machine reaches `\"visible\"` — including every re-reveal when `once={false}`. */\n\tonReveal?: () => void;\n\tchildren: ReactNode;\n}\n\n// Named so the render's conditional inline-var writes can compare a caller's\n// value against the shipped default and skip the inline write when they\n// match — the same technique Pressable's `DEFAULT_SCALE` and Magnetic's\n// `DEFAULT_RADIUS` already use. This is what keeps the CSS fallback chain\n// (in reveal.css) actually reachable: an inline style always wins over a\n// stylesheet, so writing these vars unconditionally would make\n// `--ft-reveal-duration` etc. un-themeable no matter what the CSS said.\nconst DEFAULT_DURATION = 600; // tokens.DURATIONS.entrance\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_EASING = \"cubic-bezier(0.16, 1, 0.3, 1)\"; // tokens.EASINGS.out\nconst DEFAULT_DISTANCE = 16;\n\nexport const Reveal = forwardRef<HTMLElement, RevealProps>(function Reveal(\n\t{\n\t\tpreset = \"fade-up\",\n\t\ttrigger = \"view\",\n\t\tactive = false,\n\t\tonce = true,\n\t\tthreshold = 0.1,\n\t\trootMargin = \"0px 0px -10% 0px\",\n\t\tduration = DEFAULT_DURATION,\n\t\tdelay = DEFAULT_DELAY,\n\t\teasing = DEFAULT_EASING,\n\t\tdistance = DEFAULT_DISTANCE,\n\t\tstagger = 0,\n\t\tfrom = \"first\",\n\t\tinitial = \"hidden\",\n\t\tas = \"div\",\n\t\tonReveal,\n\t\tonFocus,\n\t\tclassName,\n\t\tstyle,\n\t\tchildren,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\tconst [node, nodeRef] = useElementRef<HTMLElement>();\n\tconst composedRef = useComposedRefs(forwardedRef, nodeRef);\n\n\t// The 3-state machine (idle → armed → visible) exists so SSR/pre-hydration\n\t// content has a stable, honest name for \"nothing has run yet\" (idle) that\n\t// is distinct from \"the observer is attached and waiting\" (armed) — see\n\t// the Svelte source's README SSR section for why `initial` picks the\n\t// STARTING value here rather than a fixed constant. Only `armed` is hidden\n\t// by the CSS: `idle` paints visible, which is exactly what\n\t// `initial=\"visible\"` buys (content readable before hydration, with a\n\t// one-frame flash when the mount effect arms it), and `armed` is what\n\t// `initial=\"hidden\"` starts from (no flash, hidden until the reveal\n\t// fires). The lazy initializer is deliberately one-shot (the starting\n\t// value only) — a LATER change to the `initial` prop must not reset an\n\t// already-running reveal back to a pre-mount state.\n\tconst [state, setState] = useState<\"idle\" | \"armed\" | \"visible\">(() =>\n\t\tinitial === \"hidden\" ? \"armed\" : \"idle\"\n\t);\n\t// Mirror of `state` for the stable callbacks below: `markVisible`'s\n\t// \"fire onReveal once per reveal\" guard must read the CURRENT state from\n\t// inside long-lived observer/rAF closures, not the render that created\n\t// them.\n\tconst stateRef = useRef(state);\n\tfunction commitState(next: \"idle\" | \"armed\" | \"visible\") {\n\t\tstateRef.current = next;\n\t\tsetState(next);\n\t}\n\n\tconst emitReveal = useEventCallback(onReveal);\n\n\tconst markVisible = useEventCallback(() => {\n\t\tif (stateRef.current !== \"visible\") {\n\t\t\tcommitState(\"visible\");\n\t\t\temitReveal();\n\t\t}\n\t});\n\n\t// Only meaningful for trigger=\"view\" with once=false: drop back to\n\t// \"armed\" (never all the way to \"idle\" — idle is a pre-mount-only value)\n\t// so the CSS hidden styling reapplies and a later re-intersection reveals\n\t// again.\n\t//\n\t// Guarded by focus: a keyboard user tabbing through a long, still-visible\n\t// section can land focus inside this node and THEN have it leave the\n\t// viewport underneath them (trigger=\"view\" + once=false) or have\n\t// `active` flip false (trigger=\"manual\") — re-hiding at that moment\n\t// would fade content to opacity:0 with the caret still inside it.\n\t// `handleFocusIn` below only protects a user ARRIVING at hidden content;\n\t// this guard protects one who is already inside when a re-hide would\n\t// otherwise fire. `document` is guarded defensively even though this is\n\t// only ever reached from a browser event/effect (never during SSR).\n\tconst markArmed = useEventCallback(() => {\n\t\tif (node && typeof document !== \"undefined\" && node.contains(document.activeElement)) return;\n\t\tcommitState(\"armed\");\n\t});\n\n\t// Runs exactly once, regardless of any prop changing later — the\n\t// idle→armed flip is a one-time mount bookkeeping step, not something\n\t// that should re-fire (or, worse, re-run every time `state` changes\n\t// afterward, which listing `state` as a dependency of an effect that also\n\t// WRITES it would do).\n\t//\n\t// A LAYOUT effect, not a passive one (contract §4): Svelte's `onMount`\n\t// completes before the browser's first paint and this flip has to as\n\t// well. The armed rule carries no transition, so a flip that landed\n\t// after a paint would show the content, snap it to hidden, and only\n\t// then fade it back in — a blink the Svelte component never produces.\n\t// Nothing else in this component is relied on to pull the flip forward.\n\t// The server path is untouched: the initializer still emits `idle` in\n\t// the server HTML, which is the whole point of `initial=\"visible\"`.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (stateRef.current === \"idle\") commitState(\"armed\");\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount bookkeeping\n\t}, []);\n\n\t// trigger=\"view\": the IntersectionObserver path, driven through the\n\t// internals hook rather than the framework-free core. The hook takes the\n\t// NODE (convention C-1), observes only while `enabled`, and — the reason\n\t// it is not a hand-rolled effect here — runs in the LAYOUT phase\n\t// (contract §4): the core's no-IntersectionObserver branch answers\n\t// `onChange(true)` synchronously, so on a browser without one a passive\n\t// effect would paint a frame of hidden content before the fail-visible\n\t// answer landed. It also owns the `once` bookkeeping this component would\n\t// otherwise duplicate: `once` is read live on every fire (a rebuild would\n\t// throw away the core's already-fired guard), which is why it is not a\n\t// dependency. The returned state is unused — `data-state` is this\n\t// component's own three-state machine, not the observer's boolean.\n\tuseInView(node, {\n\t\tenabled: trigger === \"view\",\n\t\tonce,\n\t\tthreshold,\n\t\trootMargin,\n\t\tonChange: (inViewNow) => {\n\t\t\tif (inViewNow) markVisible();\n\t\t\telse if (!once) markArmed();\n\t\t},\n\t});\n\n\t// trigger=\"mount\": reveal on the next animation frame after mount, so\n\t// the hidden→visible transition actually has a \"from\" frame to start\n\t// from instead of painting already-visible on the very first frame.\n\tuseEffect(() => {\n\t\tif (trigger !== \"mount\" || !node) return;\n\t\tlet cancelled = false;\n\t\tconst raf = requestAnimationFrame(() => {\n\t\t\tif (!cancelled) markVisible();\n\t\t});\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t\tcancelAnimationFrame(raf);\n\t\t};\n\t}, [trigger, node, markVisible]);\n\n\t// trigger=\"manual\": data-state mirrors `active` directly, every time\n\t// either prop changes. Never re-enters \"idle\".\n\tuseEffect(() => {\n\t\tif (trigger !== \"manual\") return;\n\t\tif (active) markVisible();\n\t\telse markArmed();\n\t}, [trigger, active, markVisible, markArmed]);\n\n\t// A keyboard user can Tab into content that LOOKS invisible (opacity: 0\n\t// is still focusable — the frozen contract forbids visibility/display\n\t// specifically so hidden content never leaves the tab order). Revealing\n\t// on focusin means hidden content never has to be reached blind. This\n\t// runs regardless of `trigger`, including \"manual\": if a manual reveal's\n\t// `active` is still false when focus arrives, focusin wins and shows the\n\t// content anyway — there's no accessible reason to make a keyboard user\n\t// wait for an external toggle a mouse user never had to wait for either.\n\t// React's `onFocus` is delivered via the native `focusin` event, so this\n\t// is the same bubbling listener the Svelte source attaches.\n\tfunction handleFocusIn(event: FocusEvent<HTMLElement>) {\n\t\tmarkVisible();\n\t\t// `onFocus` is pulled out of `restProps` (see the props destructure\n\t\t// above) specifically so a caller's own handler still runs — leaving\n\t\t// it inside `restProps` would let this component's own `onFocus`\n\t\t// silently win the spread (restProps is spread before this attribute,\n\t\t// see below) and discard the caller's.\n\t\tonFocus?.(event);\n\t}\n\n\t// Stagger: only when stagger > 0. Walks the DIRECT ELEMENT children\n\t// (bare text nodes can't carry a CSS custom property) and writes each\n\t// one's computed delay as an inline var, re-indexing whenever the child\n\t// list itself changes (a list growing, for instance) via\n\t// MutationObserver — a static :nth-child sheet (see blur-reveal, this\n\t// component's predecessor) can never do this for an arbitrary/changing\n\t// child count.\n\t//\n\t// `SVGElement` is accepted alongside `HTMLElement` because the stagger CSS\n\t// targets EVERY direct element child (`> *`): a row of bare `<svg>` icons\n\t// is a real, common case, and an `instanceof HTMLElement`-only walk would\n\t// animate each icon (the CSS matched) while leaving every one of them on\n\t// the 0ms fallback delay — the whole row arriving at once. Both\n\t// interfaces expose `style`, which is all this needs.\n\tuseEffect(() => {\n\t\tif (stagger <= 0 || !node) return;\n\t\tconst root = node;\n\n\t\tfunction apply() {\n\t\t\tconst kids = Array.from(root.children).filter(\n\t\t\t\t(el): el is HTMLElement | SVGElement =>\n\t\t\t\t\t(el instanceof HTMLElement || el instanceof SVGElement) &&\n\t\t\t\t\t!el.hasAttribute(\"data-reveal-skip\")\n\t\t\t);\n\t\t\tfor (const [i, kid] of kids.entries()) {\n\t\t\t\tconst ms = staggerDelay(i, kids.length, stagger, from, STAGGER_CAPS.item);\n\t\t\t\tkid.style.setProperty(\"--ft-reveal-child-delay\", `${ms}ms`);\n\t\t\t}\n\t\t}\n\n\t\tapply();\n\t\tconst observer = new MutationObserver(apply);\n\t\tobserver.observe(root, { childList: true });\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t\t// Stagger dropping back to 0 (or the component unmounting) leaves\n\t\t\t// this var behind otherwise — harmless today (the CSS only reads\n\t\t\t// it under `[data-stagger]`), but it's state this component wrote\n\t\t\t// and no longer owns once the effect that set it tears down.\n\t\t\tfor (const el of Array.from(root.children)) {\n\t\t\t\tif (el instanceof HTMLElement || el instanceof SVGElement) {\n\t\t\t\t\tel.style.removeProperty(\"--ft-reveal-child-delay\");\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}, [node, stagger, from]);\n\n\t// Written only when the caller's value differs from the shipped default —\n\t// see the DEFAULT_* comment above. Vars land AFTER the caller's `style`\n\t// so they win, matching Svelte's style: directives beating the style\n\t// attribute.\n\tconst vars: Record<string, string> = {};\n\tif (duration !== DEFAULT_DURATION) vars[\"--ft-reveal-duration\"] = `${duration}ms`;\n\tif (delay !== DEFAULT_DELAY) vars[\"--ft-reveal-delay\"] = `${delay}ms`;\n\tif (easing !== DEFAULT_EASING) vars[\"--ft-reveal-easing\"] = easing;\n\tif (distance !== DEFAULT_DISTANCE) vars[\"--ft-reveal-distance\"] = `${distance}px`;\n\n\treturn createElement(\n\t\tas,\n\t\t{\n\t\t\t...restProps,\n\t\t\tref: composedRef,\n\t\t\tclassName: cn(\"ft-reveal\", className),\n\t\t\t\"data-state\": state,\n\t\t\t\"data-preset\": preset,\n\t\t\t\"data-stagger\": stagger > 0 ? \"\" : undefined,\n\t\t\tstyle: { ...style, ...vars } as CSSProperties,\n\t\t\tonFocus: handleFocusIn,\n\t\t},\n\t\tchildren\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/reveal/Reveal.tsx"},{"path":"react/src/components/reveal/index.ts","content":"\"use client\";\n\nexport { Reveal } from \"./Reveal\";\nexport type { RevealProps } from \"./Reveal\";\n","type":"registry:ui","target":"@ui/fancy/components/reveal/index.ts"},{"path":"react/src/components/reveal/reveal.css","content":"/*\n * Ported from the Svelte source's <style> block. Every rule was already\n * anchored on the component's own root class (.ft-reveal) there, so nothing\n * is port-added; Svelte's `:global(*)` child selectors become plain `*`\n * (this file is global CSS to begin with).\n */\n\n/*\n * Composed once here (rather than inline per data-preset selector below)\n * so both the root path (stagger=0) and the per-child path (stagger>0)\n * read the exact same formula — a custom property is inherited, so a\n * staggered child picks this up straight from its parent without\n * needing its own copy. The tx/ty/scale sign vars are plain data (see\n * the data-preset rules right below) and stay defined outside the\n * reduced-motion query on purpose: they do nothing on their own, so\n * there's nothing to gate.\n */\n.ft-reveal {\n\t--ft-reveal-hidden-transform: translate(\n\t\t\tcalc(var(--ft-reveal-tx, 0) * var(--ft-reveal-distance, 16px)),\n\t\t\tcalc(var(--ft-reveal-ty, 0) * var(--ft-reveal-distance, 16px))\n\t\t)\n\t\tscale(var(--ft-reveal-scale, 1));\n}\n\n/* Signs mirror internals/motion/presets.ts's PRESETS table exactly —\n   \"fade\" sets none of these (no transform term at all, matching\n   preset()'s own cssFor(), which never emits a no-op `transform: none`\n   for fade either). */\n.ft-reveal[data-preset=\"fade-up\"] {\n\t--ft-reveal-ty: 1; /* PRESETS[\"fade-up\"].y */\n}\n.ft-reveal[data-preset=\"fade-down\"] {\n\t--ft-reveal-ty: -1; /* PRESETS[\"fade-down\"].y */\n}\n.ft-reveal[data-preset=\"fade-left\"] {\n\t--ft-reveal-tx: 1; /* PRESETS[\"fade-left\"].x */\n}\n.ft-reveal[data-preset=\"fade-right\"] {\n\t--ft-reveal-tx: -1; /* PRESETS[\"fade-right\"].x */\n}\n.ft-reveal[data-preset=\"scale\"] {\n\t--ft-reveal-scale: 0.92; /* PRESETS.scale.scale — fixed, no matching prop */\n}\n\n/*\n * Hidden styling exists ONLY here: a user who prefers reduced motion, or\n * whose browser reports no scripting capability at all, never has this\n * rule apply — the resting (visible, untransformed) state is always the\n * fallback, never something JS has to force. `visibility`/`display` are\n * deliberately never used (see the frozen contract) so hidden content\n * stays reachable by Tab — see `handleFocusIn` in Reveal.tsx.\n */\n@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {\n\t/* Root path (stagger=0): the root itself is the thing that animates.\n\t   Only `armed` hides. `idle` (the `initial=\"visible\"` server paint)\n\t   stays visible, and the transition is declared on the `visible`\n\t   state alone so the mount-time idle→armed flip snaps instantly\n\t   instead of fading content out over the entrance duration. */\n\t.ft-reveal:not([data-stagger])[data-state=\"armed\"] {\n\t\topacity: 0;\n\t\ttransform: var(--ft-reveal-hidden-transform);\n\t}\n\t.ft-reveal:not([data-stagger])[data-state=\"visible\"] {\n\t\ttransition-property: opacity, transform;\n\t\ttransition-duration: var(\n\t\t\t--ft-reveal-duration,\n\t\t\tvar(--ft-duration-entrance, 600ms)\n\t\t); /* tokens.DURATIONS.entrance */\n\t\ttransition-timing-function: var(\n\t\t\t--ft-reveal-easing,\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1))\n\t\t); /* tokens.EASINGS.out */\n\t\ttransition-delay: var(--ft-reveal-delay, 0ms);\n\t}\n\n\t/* Stagger path (stagger>0): the root stays static (no opacity/transform\n\t   of its own — see the \"double-animation\" note in the Wave-1 audit)\n\t   and each direct element child animates on its own, offset by\n\t   --ft-reveal-child-delay (written per child by the stagger effect in\n\t   Reveal.tsx). data-reveal-skip opts a child out entirely. */\n\t.ft-reveal[data-stagger][data-state=\"armed\"] > *:not([data-reveal-skip]) {\n\t\topacity: 0;\n\t\ttransform: var(--ft-reveal-hidden-transform);\n\t}\n\t.ft-reveal[data-stagger][data-state=\"visible\"] > *:not([data-reveal-skip]) {\n\t\ttransition-property: opacity, transform;\n\t\ttransition-duration: var(\n\t\t\t--ft-reveal-duration,\n\t\t\tvar(--ft-duration-entrance, 600ms)\n\t\t); /* tokens.DURATIONS.entrance */\n\t\ttransition-timing-function: var(\n\t\t\t--ft-reveal-easing,\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1))\n\t\t); /* tokens.EASINGS.out */\n\t\ttransition-delay: calc(var(--ft-reveal-delay, 0ms) + var(--ft-reveal-child-delay, 0ms));\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/reveal/reveal.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/reveal?framework=react"}}