{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"compare","type":"registry:ui","title":"Compare","description":"Before/after image comparison slider split by a blade of light: a white-hot seam with a chromatic fringe, pulses running along it and a trail that stretches with the speed of the gesture","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["media"],"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/compare/Compare.tsx","content":"\"use client\";\n\n/**\n * Compare - a before/after slider split by a blade of light\n *\n * The seam is a beam: a white-hot core with a chromatic fringe, a glow\n * that spills onto both sides, and pulses of light running along it. Moving\n * the seam leaves a trail behind it that stretches with the speed of the\n * gesture and settles when it stops.\n */\nimport {\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\ttype CSSProperties,\n\ttype KeyboardEvent,\n\ttype MouseEvent,\n\ttype ReactNode,\n\ttype TouchEvent,\n} from \"react\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { cn } from \"../../utils\";\nimport \"./compare.css\";\n\nexport const COMPARE_BEAM_COLORS = [\"#22d3ee\", \"#818cf8\", \"#f472b6\"];\n\n/** Three colours for the beam (left fringe, centre, right fringe), from one to many given. */\nexport function beamPalette(colors: string[] | undefined): [string, string, string] {\n\tconst list = (colors ?? []).filter(Boolean);\n\tif (list.length === 0) return COMPARE_BEAM_COLORS as [string, string, string];\n\tif (list.length === 1) return [list[0]!, list[0]!, list[0]!];\n\tif (list.length === 2) return [list[0]!, `color-mix(in srgb, ${list[0]}, ${list[1]})`, list[1]!];\n\treturn [list[0]!, list[Math.floor((list.length - 1) / 2)]!, list[list.length - 1]!];\n}\n\n/**\n * One frame of the beam's motion: the trail follows the seam's velocity\n * (px per frame, signed) and decays; the energy eases toward its target\n * and is kicked up by speed.\n */\nexport function beamStep(\n\ttrail: number,\n\tenergy: number,\n\tdeltaPx: number,\n\ttarget: number\n): { trail: number; energy: number } {\n\tconst nextTrail = Math.max(-140, Math.min(140, trail * 0.86 + deltaPx * 1.6));\n\tconst kick = Math.min(1, Math.abs(deltaPx) / 12);\n\tconst nextEnergy = Math.min(1, energy + (Math.max(target, kick) - energy) * 0.12);\n\treturn { trail: Math.abs(nextTrail) < 0.25 ? 0 : nextTrail, energy: nextEnergy };\n}\n\n/** Keyboard step for the slider, in %: arrows move 2, Shift+arrows 10, Home/End jump to the ends. */\nexport function keyStep(key: string, shift: boolean, current: number): number | null {\n\tconst step = shift ? 10 : 2;\n\tswitch (key) {\n\t\tcase \"ArrowLeft\":\n\t\tcase \"ArrowDown\":\n\t\t\treturn Math.max(0, current - step);\n\t\tcase \"ArrowRight\":\n\t\tcase \"ArrowUp\":\n\t\t\treturn Math.min(100, current + step);\n\t\tcase \"PageDown\":\n\t\t\treturn Math.max(0, current - 10);\n\t\tcase \"PageUp\":\n\t\t\treturn Math.min(100, current + 10);\n\t\tcase \"Home\":\n\t\t\treturn 0;\n\t\tcase \"End\":\n\t\t\treturn 100;\n\t\tdefault:\n\t\t\treturn null;\n\t}\n}\n\nexport interface CompareProps {\n\tfirstImage?: string;\n\tsecondImage?: string;\n\tfirstImageAlt?: string;\n\tsecondImageAlt?: string;\n\tclassName?: string;\n\tfirstContentClass?: string;\n\tsecondContentClass?: string;\n\tinitialSliderPercentage?: number;\n\tslideMode?: \"hover\" | \"drag\";\n\tshowHandlebar?: boolean;\n\tautoplay?: boolean;\n\tautoplayDuration?: number;\n\t/** Beam colours: left fringe, centre, right fringe (one or more colours) */\n\tbeamColors?: string[];\n\t/** Accessible name of the slider */\n\tlabel?: string;\n\t/**\n\t * @deprecated Use `label`. Kept so code written against the earlier port\n\t * keeps its accessible name; `label` wins when both are given.\n\t */\n\tariaLabel?: string;\n\tonpercentagechange?: (percentage: number) => void;\n\tondragstart?: () => void;\n\tondragend?: () => void;\n\tonhoverenter?: () => void;\n\tonhoverleave?: () => void;\n\tfirstContent?: ReactNode;\n\tsecondContent?: ReactNode;\n\thandle?: ReactNode;\n}\n\nexport function Compare({\n\tfirstImage = \"\",\n\tsecondImage = \"\",\n\tfirstImageAlt = \"First image\",\n\tsecondImageAlt = \"Second image\",\n\tclassName = \"\",\n\tfirstContentClass = \"\",\n\tsecondContentClass = \"\",\n\tinitialSliderPercentage = 50,\n\tslideMode = \"hover\",\n\tshowHandlebar = true,\n\tautoplay = false,\n\tautoplayDuration = 5000,\n\tbeamColors,\n\tlabel,\n\tariaLabel,\n\tonpercentagechange,\n\tondragstart,\n\tondragend,\n\tonhoverenter,\n\tonhoverleave,\n\tfirstContent,\n\tsecondContent,\n\thandle,\n}: CompareProps) {\n\tconst accessibleName = label ?? ariaLabel ?? \"Comparison slider\";\n\tconst reducedMotion = useReducedMotion();\n\tconst reducedMotionRef = useLiveRef(reducedMotion);\n\n\tconst sliderRef = useRef<HTMLDivElement>(null);\n\t// Each piece of state the markup reads is mirrored in a ref: the rAF loops\n\t// (autoplay, glide, beam) are long-lived closures and must see live values,\n\t// as the reference's closures read its $state live.\n\tconst [sliderXPercent, setSliderXPercentState] = useState(initialSliderPercentage);\n\tconst percentRef = useRef(initialSliderPercentage);\n\tconst [isDragging, setIsDraggingState] = useState(false);\n\tconst isDraggingRef = useRef(false);\n\tconst [isMouseOver, setIsMouseOverState] = useState(false);\n\tconst isMouseOverRef = useRef(false);\n\tconst [isInteracting, setIsInteractingState] = useState(false);\n\tconst isInteractingRef = useRef(false);\n\n\tconst autoplayRAF = useRef<number | null>(null);\n\tconst returnRAF = useRef<number | null>(null);\n\t// A pointer move commits on the next frame. Keeping the handle lets a burst\n\t// of moves collapse into one commit and lets teardown drop a frame that no\n\t// longer has a component to commit into.\n\tconst moveRAF = useRef<number | null>(null);\n\n\t// the beam's motion: trail (px, signed) and energy (0 idle … 1 lit)\n\tconst [trail, setTrail] = useState(0);\n\tconst [energy, setEnergy] = useState(0);\n\tconst trailRef = useRef(0);\n\tconst energyRef = useRef(0);\n\tconst beamRAF = useRef<number | null>(null);\n\tconst lastPx = useRef<number | null>(null);\n\n\t// Long-lived loops announce through the latest render's callback.\n\tconst onPercentageChangeRef = useLiveRef(onpercentagechange);\n\n\tconst palette = beamPalette(beamColors);\n\n\tfunction setSliderX(p: number): void {\n\t\tpercentRef.current = p;\n\t\tsetSliderXPercentState(p);\n\t}\n\tfunction setIsDragging(v: boolean): void {\n\t\tisDraggingRef.current = v;\n\t\tsetIsDraggingState(v);\n\t}\n\tfunction setIsMouseOver(v: boolean): void {\n\t\tisMouseOverRef.current = v;\n\t\tsetIsMouseOverState(v);\n\t}\n\tfunction setIsInteracting(v: boolean): void {\n\t\tisInteractingRef.current = v;\n\t\tsetIsInteractingState(v);\n\t}\n\n\tfunction setPercent(p: number): void {\n\t\tconst next = Math.max(0, Math.min(100, p));\n\t\tsetSliderX(next);\n\t\tonPercentageChangeRef.current?.(next);\n\t}\n\n\tfunction startAutoplay(): void {\n\t\tif (!autoplay || isMouseOverRef.current || isDraggingRef.current) return;\n\n\t\tconst startTime = Date.now();\n\t\tfunction animate(): void {\n\t\t\tif (isMouseOverRef.current || isDraggingRef.current) {\n\t\t\t\tif (autoplayRAF.current) cancelAnimationFrame(autoplayRAF.current);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst elapsedTime = Date.now() - startTime;\n\t\t\tconst progress = (elapsedTime % (autoplayDuration * 2)) / autoplayDuration;\n\t\t\t// eased back-and-forth: slow at the ends, quick through the middle\n\t\t\tconst linear = progress <= 1 ? progress : 2 - progress;\n\t\t\tconst eased = linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;\n\n\t\t\tsetPercent(eased * 100);\n\t\t\tautoplayRAF.current = requestAnimationFrame(animate);\n\t\t}\n\n\t\tanimate();\n\t}\n\n\tfunction stopAutoplay(): void {\n\t\tif (autoplayRAF.current) {\n\t\t\tcancelAnimationFrame(autoplayRAF.current);\n\t\t\tautoplayRAF.current = null;\n\t\t}\n\t}\n\n\tfunction stopReturn(): void {\n\t\tif (returnRAF.current) {\n\t\t\tcancelAnimationFrame(returnRAF.current);\n\t\t\treturnRAF.current = null;\n\t\t}\n\t}\n\n\tfunction stopMoveFrame(): void {\n\t\tif (moveRAF.current) {\n\t\t\tcancelAnimationFrame(moveRAF.current);\n\t\t\tmoveRAF.current = null;\n\t\t}\n\t}\n\n\t/** Glide the seam back to its resting place instead of snapping. */\n\tfunction glideTo(target: number): void {\n\t\tstopReturn();\n\t\tif (reducedMotionRef.current) {\n\t\t\tsetPercent(target);\n\t\t\treturn;\n\t\t}\n\t\tconst from = percentRef.current;\n\t\tconst start = performance.now();\n\t\tconst duration = 520;\n\t\tconst step = (now: number) => {\n\t\t\tconst k = Math.min(1, (now - start) / duration);\n\t\t\tconst e = 1 - (1 - k) ** 3;\n\t\t\tsetPercent(from + (target - from) * e);\n\t\t\treturnRAF.current = k < 1 ? requestAnimationFrame(step) : null;\n\t\t};\n\t\treturnRAF.current = requestAnimationFrame(step);\n\t}\n\n\tfunction mouseEnterHandler(): void {\n\t\tsetIsMouseOver(true);\n\t\tstopReturn();\n\t\tonhoverenter?.();\n\t\tif (autoplay) {\n\t\t\tstopAutoplay();\n\t\t}\n\t}\n\n\tfunction mouseLeaveHandler(): void {\n\t\tsetIsMouseOver(false);\n\t\tsetIsInteracting(false);\n\t\tonhoverleave?.();\n\n\t\tif (slideMode === \"hover\") {\n\t\t\tglideTo(initialSliderPercentage);\n\t\t}\n\t\tif (slideMode === \"drag\") {\n\t\t\tsetIsDragging(false);\n\t\t}\n\n\t\tif (autoplay) {\n\t\t\tstopReturn();\n\t\t\tstartAutoplay();\n\t\t}\n\t}\n\n\tfunction handleStart(): void {\n\t\tif (slideMode === \"drag\") {\n\t\t\tsetIsDragging(true);\n\t\t\tsetIsInteracting(true);\n\t\t\tondragstart?.();\n\t\t\tstopAutoplay();\n\t\t}\n\t}\n\n\tfunction handleEnd(): void {\n\t\tif (slideMode === \"drag\") {\n\t\t\tsetIsDragging(false);\n\t\t\tsetIsInteracting(false);\n\t\t\tondragend?.();\n\t\t\tif (autoplay && !isMouseOverRef.current) {\n\t\t\t\tstartAutoplay();\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction handleMove(clientX: number): void {\n\t\tif (!sliderRef.current) return;\n\n\t\tif (slideMode === \"hover\" || (slideMode === \"drag\" && isDraggingRef.current)) {\n\t\t\tsetIsInteracting(true);\n\t\t\tstopAutoplay();\n\t\t\tstopReturn();\n\n\t\t\tconst rect = sliderRef.current.getBoundingClientRect();\n\t\t\tconst x = clientX - rect.left;\n\t\t\tconst percent = (x / rect.width) * 100;\n\n\t\t\tstopMoveFrame();\n\t\t\tmoveRAF.current = requestAnimationFrame(() => {\n\t\t\t\tmoveRAF.current = null;\n\t\t\t\tsetPercent(percent);\n\t\t\t});\n\t\t}\n\t}\n\n\tfunction handleMouseDown(): void {\n\t\thandleStart();\n\t}\n\n\tfunction handleMouseMove(e: MouseEvent<HTMLDivElement>): void {\n\t\thandleMove(e.clientX);\n\t}\n\n\tfunction handleTouchStart(): void {\n\t\tif (!autoplay) handleStart();\n\t}\n\n\tfunction handleTouchEnd(): void {\n\t\tif (!autoplay) handleEnd();\n\t}\n\n\tfunction handleTouchMove(e: TouchEvent<HTMLDivElement>): void {\n\t\tconst touch = e.touches[0];\n\t\tif (!autoplay && touch) handleMove(touch.clientX);\n\t}\n\n\tfunction handleKeyDown(e: KeyboardEvent<HTMLDivElement>): void {\n\t\tconst next = keyStep(e.key, e.shiftKey, percentRef.current);\n\t\tif (next === null) return;\n\t\te.preventDefault();\n\t\tstopAutoplay();\n\t\tstopReturn();\n\t\tsetPercent(next);\n\t}\n\n\t/** Run the beam's motion until it has settled. */\n\tfunction wakeBeam(): void {\n\t\tif (beamRAF.current !== null || reducedMotionRef.current) return;\n\t\tconst tick = () => {\n\t\t\tconst width = sliderRef.current?.clientWidth ?? 0;\n\t\t\tconst px = (percentRef.current / 100) * width;\n\t\t\tconst delta = lastPx.current === null ? 0 : px - lastPx.current;\n\t\t\tlastPx.current = px;\n\t\t\tconst target =\n\t\t\t\tisInteractingRef.current || isDraggingRef.current ? 1 : isMouseOverRef.current ? 0.7 : 0;\n\t\t\tconst next = beamStep(trailRef.current, energyRef.current, delta, target);\n\t\t\ttrailRef.current = next.trail;\n\t\t\tenergyRef.current = next.energy;\n\t\t\tsetTrail(next.trail);\n\t\t\tsetEnergy(next.energy);\n\t\t\tconst settled = next.trail === 0 && delta === 0 && Math.abs(next.energy - target) < 0.01;\n\t\t\tbeamRAF.current = settled ? null : requestAnimationFrame(tick);\n\t\t};\n\t\tbeamRAF.current = requestAnimationFrame(tick);\n\t}\n\n\t// Watch for initialSliderPercentage changes. Layout, not passive, so it keeps\n\t// running AHEAD of the autoplay start below in the same pre-paint commit —\n\t// the order the two reference effects run in.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tsetSliderX(initialSliderPercentage);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [initialSliderPercentage]);\n\n\t// any movement of the seam (pointer, keys, autoplay, glide) wakes the beam\n\tuseEffect(() => {\n\t\twakeBeam();\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [sliderXPercent, isInteracting, isMouseOver]);\n\n\t// Watch for autoplay changes. This effect also covers the reference's\n\t// onMount start: it runs at mount, before the browser paints, so the first\n\t// frame already shows the autoplay start position.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (autoplay && !isMouseOverRef.current && !isDraggingRef.current) {\n\t\t\tstartAutoplay();\n\t\t} else {\n\t\t\tstopAutoplay();\n\t\t}\n\t\treturn stopAutoplay;\n\t\t// The loop reads autoplayDuration from its closure — restart it when\n\t\t// the duration changes so the closure stays current.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [autoplay, autoplayDuration]);\n\n\t// Teardown: every frame still pending belongs to a component that is gone.\n\tuseEffect(\n\t\t() => () => {\n\t\t\tstopAutoplay();\n\t\t\tstopReturn();\n\t\t\tstopMoveFrame();\n\t\t\tif (beamRAF.current !== null) cancelAnimationFrame(beamRAF.current);\n\t\t\tbeamRAF.current = null;\n\t\t},\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t\t[]\n\t);\n\n\tconst rootStyle = {\n\t\tposition: \"relative\",\n\t\tcursor: slideMode === \"drag\" ? (isDragging ? \"grabbing\" : \"grab\") : \"col-resize\",\n\t\t\"--cmp-c1\": palette[0],\n\t\t\"--cmp-c2\": palette[1],\n\t\t\"--cmp-c3\": palette[2],\n\t\t\"--cmp-energy\": energy.toFixed(3),\n\t\t\"--cmp-trail\": trail.toFixed(1),\n\t} as CSSProperties;\n\n\treturn (\n\t\t<div\n\t\t\tref={sliderRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"compare h-[400px] w-[400px] touch-pan-y touch-pinch-zoom overflow-hidden\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t\tstyle={rootStyle}\n\t\t\tdata-active={isInteracting || isDragging ? \"\" : undefined}\n\t\t\tonMouseMove={handleMouseMove}\n\t\t\tonMouseLeave={mouseLeaveHandler}\n\t\t\tonMouseEnter={mouseEnterHandler}\n\t\t\tonMouseDown={handleMouseDown}\n\t\t\tonMouseUp={handleEnd}\n\t\t\tonTouchStart={handleTouchStart}\n\t\t\tonTouchEnd={handleTouchEnd}\n\t\t\tonTouchMove={handleTouchMove}\n\t\t\tonKeyDown={handleKeyDown}\n\t\t\trole=\"slider\"\n\t\t\taria-label={accessibleName}\n\t\t\taria-valuenow={Math.round(sliderXPercent)}\n\t\t\taria-valuetext={`${Math.round(sliderXPercent)}% ${firstImageAlt}`}\n\t\t\taria-valuemin={0}\n\t\t\taria-valuemax={100}\n\t\t\taria-orientation=\"horizontal\"\n\t\t\ttabIndex={0}\n\t\t>\n\t\t\t{/* The seam: a blade of light, screened onto both sides */}\n\t\t\t<div\n\t\t\t\tclassName=\"compare-beam pointer-events-none absolute top-0 z-40 h-full w-px\"\n\t\t\t\tstyle={{ left: `${sliderXPercent}%` }}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t>\n\t\t\t\t<span className=\"compare-beam__glow\"></span>\n\t\t\t\t<span className=\"compare-beam__trail\"></span>\n\t\t\t\t<span className=\"compare-beam__fringe\"></span>\n\t\t\t\t<span className=\"compare-beam__core\"></span>\n\t\t\t\t<span className=\"compare-beam__pulse\"></span>\n\t\t\t\t<span className=\"compare-beam__pulse compare-beam__pulse--late\"></span>\n\t\t\t</div>\n\n\t\t\t{/* Handle, above the beam and not blended with it */}\n\t\t\t<div\n\t\t\t\tclassName=\"compare-seam pointer-events-none absolute top-0 z-[41] h-full w-px\"\n\t\t\t\tstyle={{ left: `${sliderXPercent}%` }}\n\t\t\t>\n\t\t\t\t{handle ? (\n\t\t\t\t\thandle\n\t\t\t\t) : showHandlebar ? (\n\t\t\t\t\t<div className=\"compare-handle pointer-events-auto\" aria-hidden=\"true\">\n\t\t\t\t\t\t<span className=\"compare-handle__ring\"></span>\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\tclassName=\"size-4\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 7 4 12l5 5M15 7l5 5-5 5\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\n\t\t\t{/* First Content */}\n\t\t\t<div\n\t\t\t\tclassName=\"relative z-20 size-full overflow-hidden\"\n\t\t\t\tstyle={{ pointerEvents: isInteracting ? \"none\" : \"auto\" }}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"absolute inset-0 z-20 h-full w-full flex-shrink-0 overflow-hidden rounded-2xl select-none\",\n\t\t\t\t\t\tfirstContentClass\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={{ clipPath: `inset(0 ${100 - sliderXPercent}% 0 0)` }}\n\t\t\t\t>\n\t\t\t\t\t{firstContent ? (\n\t\t\t\t\t\tfirstContent\n\t\t\t\t\t) : firstImage ? (\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\talt={firstImageAlt}\n\t\t\t\t\t\t\tsrc={firstImage}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"absolute inset-0 z-20 h-full w-full flex-shrink-0 rounded-2xl object-cover select-none\",\n\t\t\t\t\t\t\t\tfirstContentClass\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : null}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Second Content */}\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"absolute top-0 left-0 z-[19] h-full w-full overflow-hidden rounded-2xl select-none\",\n\t\t\t\t\tsecondContentClass\n\t\t\t\t)}\n\t\t\t\tstyle={{ pointerEvents: isInteracting ? \"none\" : \"auto\" }}\n\t\t\t>\n\t\t\t\t{secondContent ? (\n\t\t\t\t\tsecondContent\n\t\t\t\t) : secondImage ? (\n\t\t\t\t\t<img\n\t\t\t\t\t\talt={secondImageAlt}\n\t\t\t\t\t\tsrc={secondImage}\n\t\t\t\t\t\tclassName={cn(\"h-full w-full object-cover\", secondContentClass)}\n\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/compare/Compare.tsx"},{"path":"react/src/components/compare/StarField.tsx","content":"\"use client\";\n\nimport { memo, useMemo, type CSSProperties } from \"react\";\nimport { cn } from \"../../utils\";\nimport \"./star-field.css\";\n\nexport interface StarFieldProps {\n\t/** Number of stars to render */\n\tstarsCount?: number;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Seed for the star layout. The same seed always produces the same sky,\n\t * which is what keeps a server render and its hydration identical. Change\n\t * it to give two star fields on one page different layouts — the default\n\t * is shared, so two `<StarField />` with no seed twinkle the same way.\n\t */\n\tseed?: number;\n}\n\ninterface Star {\n\tid: number;\n\ttop: string;\n\tleft: string;\n\tsize: number;\n\ttwinkleDuration: number;\n\tdriftDuration: number;\n\tdriftDirection: number;\n\topacityStart: number;\n\topacityEnd: number;\n}\n\n/**\n * mulberry32 — a tiny deterministic PRNG. `Math.random()` cannot be used\n * here: the star list is built in the render path, so it would run once on\n * the server and again during client hydration, and the two would disagree\n * on every value — a hydration mismatch React may resolve by keeping the\n * SERVER attributes. A seed keeps both renders identical AND keeps the sky\n * in the markup.\n */\nfunction mulberry32(seed: number): () => number {\n\tlet state = seed >>> 0;\n\treturn () => {\n\t\tstate = (state + 0x6d2b79f5) >>> 0;\n\t\tlet t = state;\n\t\tt = Math.imul(t ^ (t >>> 15), t | 1);\n\t\tt ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n\t\treturn ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n\t};\n}\n\nfunction createStars(starsCount: number, seed: number): Star[] {\n\tconst rng = mulberry32(seed);\n\n\tfunction random(min: number, max: number): number {\n\t\treturn rng() * (max - min) + min;\n\t}\n\n\tfunction randomSize(): number {\n\t\treturn rng() < 0.5 ? 1 : 2;\n\t}\n\n\treturn Array.from(\n\t\t{ length: starsCount },\n\t\t(_, i): Star => ({\n\t\t\tid: i,\n\t\t\ttop: `${random(0, 100)}%`,\n\t\t\tleft: `${random(0, 100)}%`,\n\t\t\tsize: randomSize(),\n\t\t\ttwinkleDuration: random(2, 4),\n\t\t\tdriftDuration: random(5, 10),\n\t\t\tdriftDirection: random(-50, 50),\n\t\t\topacityStart: random(0.1, 0.3),\n\t\t\topacityEnd: random(0.7, 1),\n\t\t})\n\t);\n}\n\n/**\n * Memoised, because the sky is a constant and its host is not: Compare\n * re-renders on every autoplay frame and every pointer move while it passes\n * this component nothing but static literals. Without the boundary each of\n * those frames would rebuild every star element and diff its inline style to\n * write nothing — work the Svelte source never does, where the `{#each}` block\n * is built once and only the divider's own two style properties change.\n */\nexport const StarField = memo(function StarField({\n\tstarsCount = 130,\n\tclassName,\n\tseed = 1,\n}: StarFieldProps) {\n\t// Generated once per (starsCount, seed) — the Svelte source builds the\n\t// list once at component creation; recomputing on unrelated re-renders\n\t// would reshuffle the sky.\n\tconst stars = useMemo(() => createStars(starsCount, seed), [starsCount, seed]);\n\n\treturn (\n\t\t<div className={cn(\"fancy-star-field absolute inset-0 overflow-hidden\", className)}>\n\t\t\t{stars.map((star) => (\n\t\t\t\t<div\n\t\t\t\t\tkey={star.id}\n\t\t\t\t\tclassName=\"star absolute rounded-full bg-white\"\n\t\t\t\t\tstyle={\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttop: star.top,\n\t\t\t\t\t\t\tleft: star.left,\n\t\t\t\t\t\t\twidth: `${star.size}px`,\n\t\t\t\t\t\t\theight: `${star.size}px`,\n\t\t\t\t\t\t\t\"--fancy-ui-twinkle-duration\": `${star.twinkleDuration}s`,\n\t\t\t\t\t\t\t\"--fancy-ui-drift-duration\": `${star.driftDuration}s`,\n\t\t\t\t\t\t\t\"--fancy-ui-drift-direction\": `${star.driftDirection}px`,\n\t\t\t\t\t\t\t\"--fancy-ui-opacity-start\": star.opacityStart,\n\t\t\t\t\t\t\t\"--fancy-ui-opacity-end\": star.opacityEnd,\n\t\t\t\t\t\t} as CSSProperties\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/compare/StarField.tsx"},{"path":"react/src/components/compare/compare.css","content":"/*\n * Compare: the seam as a blade of light.\n *\n * Mirrors the <style> block of the reference component. Every rule is\n * anchored on the root `.compare` class (the reference's own root class),\n * because plain CSS is global where the reference's rules were compiler-scoped.\n * Keyframe names, class names and custom properties are kept identical.\n */\n\n@property --cmp-angle {\n\tsyntax: \"<angle>\";\n\tinherits: false;\n\tinitial-value: 0deg;\n}\n\n.compare:focus-visible {\n\toutline: 2px solid var(--cmp-c2);\n\toutline-offset: 3px;\n}\n\n/* the whole beam screens onto the pictures: light adds, never darkens */\n.compare .compare-beam {\n\tmix-blend-mode: screen;\n}\n\n.compare .compare-beam > span {\n\tposition: absolute;\n\tpointer-events: none;\n}\n\n/* The light the beam spills on both sides: chromatic, brighter when active. */\n.compare .compare-beam__glow {\n\ttop: 0;\n\tbottom: 0;\n\tleft: -70px;\n\twidth: 140px;\n\tbackground: linear-gradient(\n\t\tto right,\n\t\ttransparent,\n\t\tcolor-mix(in srgb, var(--cmp-c1) 55%, transparent) 38%,\n\t\tvar(--cmp-c2) 50%,\n\t\tcolor-mix(in srgb, var(--cmp-c3) 55%, transparent) 62%,\n\t\ttransparent\n\t);\n\t-webkit-mask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);\n\tmask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);\n\tfilter: blur(18px);\n\topacity: calc(0.35 + var(--cmp-energy) * 0.55);\n}\n\n/* The trail: stretched out behind the seam by its speed (the sign picks the side). */\n.compare .compare-beam__trail {\n\ttop: 12%;\n\tbottom: 12%;\n\tleft: 0;\n\twidth: 100px;\n\ttransform-origin: left center;\n\ttransform: scaleX(calc(var(--cmp-trail) / -100));\n\tbackground: linear-gradient(\n\t\tto right,\n\t\t#fff,\n\t\tvar(--cmp-c2) 18%,\n\t\tcolor-mix(in srgb, var(--cmp-c3) 50%, transparent) 55%,\n\t\ttransparent\n\t);\n\t-webkit-mask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);\n\tmask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);\n\tfilter: blur(6px);\n\topacity: 0.75;\n}\n\n/* A chromatic fringe either side of the core. */\n.compare .compare-beam__fringe {\n\ttop: 0;\n\tbottom: 0;\n\tleft: -4px;\n\twidth: 9px;\n\tbackground: linear-gradient(\n\t\tto right,\n\t\tvar(--cmp-c1),\n\t\ttransparent 45%,\n\t\ttransparent 55%,\n\t\tvar(--cmp-c3)\n\t);\n\t-webkit-mask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);\n\tmask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);\n\tfilter: blur(1.5px);\n\topacity: calc(0.55 + var(--cmp-energy) * 0.45);\n}\n\n/* The white-hot core. */\n.compare .compare-beam__core {\n\ttop: 0;\n\tbottom: 0;\n\tleft: -0.75px;\n\twidth: 1.5px;\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\ttransparent,\n\t\tcolor-mix(in srgb, var(--cmp-c2) 40%, #fff) 6%,\n\t\t#fff 30%,\n\t\t#fff 70%,\n\t\tcolor-mix(in srgb, var(--cmp-c2) 40%, #fff) 94%,\n\t\ttransparent\n\t);\n\tbox-shadow: 0 0 6px 0.5px color-mix(in srgb, var(--cmp-c2) 80%, transparent);\n}\n\n/* Pulses of light running down the seam. */\n.compare .compare-beam__pulse {\n\ttop: 0;\n\tleft: -1.5px;\n\twidth: 3px;\n\theight: 26%;\n\tborder-radius: 999px;\n\tbackground: linear-gradient(to bottom, transparent, #fff 50%, transparent);\n\tfilter: drop-shadow(0 0 4px var(--cmp-c2)) drop-shadow(0 0 10px var(--cmp-c1));\n\topacity: 0;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.compare .compare-beam__pulse {\n\t\tanimation: cmp-pulse 3.2s cubic-bezier(0.45, 0, 0.55, 1) infinite;\n\t}\n\t.compare .compare-beam__pulse--late {\n\t\tanimation-delay: -1.6s;\n\t\tanimation-duration: 3.2s;\n\t}\n\t.compare[data-active] .compare-beam__pulse {\n\t\tanimation-duration: 1.6s;\n\t}\n\t.compare[data-active] .compare-beam__pulse--late {\n\t\tanimation-delay: -0.8s;\n\t}\n\t.compare .compare-handle__ring {\n\t\tanimation: cmp-spin 4s linear infinite;\n\t}\n\t.compare[data-active] .compare-handle__ring {\n\t\tanimation-duration: 1.4s;\n\t}\n}\n\n@keyframes cmp-pulse {\n\t0% {\n\t\ttransform: translateY(-100%);\n\t\topacity: 0;\n\t}\n\t15% {\n\t\topacity: 1;\n\t}\n\t85% {\n\t\topacity: 1;\n\t}\n\t100% {\n\t\ttransform: translateY(385%);\n\t\topacity: 0;\n\t}\n}\n\n/* The handle: a glass bead on the beam with a ring of light round it. */\n.compare .compare-handle {\n\tposition: absolute;\n\ttop: 50%;\n\tleft: 0;\n\tz-index: 30;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 34px;\n\theight: 34px;\n\tborder-radius: 999px;\n\ttransform: translate(-50%, -50%) scale(calc(1 + var(--cmp-energy) * 0.08));\n\tcolor: #fff;\n\tbackground: rgb(255 255 255 / 0.12);\n\tbackdrop-filter: blur(10px) saturate(1.4);\n\t-webkit-backdrop-filter: blur(10px) saturate(1.4);\n\tbox-shadow:\n\t\tinset 0 1px 0 rgb(255 255 255 / 0.35),\n\t\tinset 0 0 0 1px rgb(255 255 255 / 0.18),\n\t\t0 4px 16px rgb(0 0 0 / 0.35),\n\t\t0 0 calc(8px + var(--cmp-energy) * 18px) color-mix(in srgb, var(--cmp-c2) 70%, transparent);\n\tcursor: inherit;\n}\n\n.compare .compare-handle svg {\n\tfilter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.45));\n}\n\n.compare .compare-handle__ring {\n\tposition: absolute;\n\tinset: -1.5px;\n\tborder-radius: inherit;\n\tpadding: 1.5px;\n\tbackground: conic-gradient(\n\t\tfrom var(--cmp-angle),\n\t\ttransparent 0%,\n\t\tvar(--cmp-c1) 18%,\n\t\t#fff 30%,\n\t\tvar(--cmp-c3) 42%,\n\t\ttransparent 58%,\n\t\ttransparent 100%\n\t);\n\t-webkit-mask:\n\t\tlinear-gradient(#000 0 0) content-box,\n\t\tlinear-gradient(#000 0 0);\n\t-webkit-mask-composite: xor;\n\tmask:\n\t\tlinear-gradient(#000 0 0) content-box,\n\t\tlinear-gradient(#000 0 0);\n\tmask-composite: exclude;\n\topacity: calc(0.45 + var(--cmp-energy) * 0.55);\n}\n\n@keyframes cmp-spin {\n\tto {\n\t\t--cmp-angle: 360deg;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/compare/compare.css"},{"path":"react/src/components/compare/index.ts","content":"\"use client\";\n\nexport { Compare } from \"./Compare\";\nexport type { CompareProps } from \"./Compare\";\nexport { StarField } from \"./StarField\";\nexport type { StarFieldProps } from \"./StarField\";\n","type":"registry:ui","target":"@ui/fancy/components/compare/index.ts"},{"path":"react/src/components/compare/star-field.css","content":"/*\n * `.fancy-star-field` is a PORT-ADDED anchor (PORTING.md, styling rule 2).\n * The Svelte source has no root class of its own: its compiler scoped `.star`,\n * which would leak into any consumer element named .star once the rules ship\n * as plain global CSS. The anchor is the first token of the root `cn()` call\n * in StarField.tsx.\n */\n.fancy-star-field .star {\n\topacity: var(--fancy-ui-opacity-start);\n\tanimation:\n\t\ttwinkle var(--fancy-ui-twinkle-duration) ease-in-out infinite alternate,\n\t\tdrift var(--fancy-ui-drift-duration) linear infinite;\n}\n\n@keyframes twinkle {\n\t0% {\n\t\topacity: var(--fancy-ui-opacity-start);\n\t}\n\t100% {\n\t\topacity: var(--fancy-ui-opacity-end);\n\t}\n}\n\n@keyframes drift {\n\t0% {\n\t\ttransform: translate(0, 0);\n\t}\n\t25% {\n\t\ttransform: translate(\n\t\t\tvar(--fancy-ui-drift-direction),\n\t\t\tcalc(var(--fancy-ui-drift-direction) / 2)\n\t\t);\n\t}\n\t50% {\n\t\ttransform: translate(\n\t\t\tcalc(var(--fancy-ui-drift-direction) / 2),\n\t\t\tvar(--fancy-ui-drift-direction)\n\t\t);\n\t}\n\t75% {\n\t\ttransform: translate(\n\t\t\tcalc(var(--fancy-ui-drift-direction) * -1),\n\t\t\tcalc(var(--fancy-ui-drift-direction) / 2)\n\t\t);\n\t}\n\t100% {\n\t\ttransform: translate(0, 0);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/compare/star-field.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/compare?framework=react"}}