{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-background",
  "title": "Ripple Background",
  "description": "Rings spreading behind a section: still lines, a slow pulse, a turning sonar sweep or several sources, plus a ring from where someone pressed.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/ripple-background.tsx",
      "content": "import type * as React from \"react\"\nimport { useMemo, useRef, useState } from \"react\"\nimport { LIGHT_TOKEN_CLASS, LOOP_CLASS, loopStyle, playStyle, useBackgroundPlayback } from \"@/registry/miseui/ui/background-loop\"\nimport { addPressRing, pondSources, ringLayout, waveLayout, type PressRing } from \"@/registry/miseui/ui/ripple-background-layout\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype RippleBackgroundVariant = \"rings\" | \"pulse\" | \"sonar\" | \"pond\"\ntype RippleBackgroundEffect = \"press\"\n\nconst CENTER = \"translate(-50%,-50%)\"\nconst PULSE_SECONDS = 12\nconst PULSE_PEAK = 0.6\nconst SONAR_SECONDS = 12\nconst SWEEP_OPACITY = 0.18\nconst POND_PEAK = 0.55\nconst POND_SIZE = 58\n\nconst KEYFRAMES = [\n  `@keyframes mise-ripple-spread{0%{transform:${CENTER} scale(.06);opacity:0}8%{opacity:var(--mise-o)}100%{transform:${CENTER} scale(1);opacity:0}}`,\n  `@keyframes mise-ripple-turn{from{transform:${CENTER} rotate(0deg)}to{transform:${CENTER} rotate(360deg)}}`,\n].join(\"\")\n\nconst PRESS_KEYFRAMES = [\n  `@keyframes mise-ripple-press{0%{transform:${CENTER} scale(.05);opacity:.7}100%{transform:${CENTER} scale(1);opacity:0}}`,\n  `@keyframes mise-ripple-press-still{0%{transform:${CENTER} scale(.5);opacity:.5}100%{transform:${CENTER} scale(.5);opacity:0}}`,\n].join(\"\")\n\n/** Every ring ends in nothing before the box edge, so no arc is cut off by a straight line. */\nconst MASK = \"radial-gradient(ellipse closest-side, #000 35%, transparent 100%)\"\nconst RING_CLASS = \"absolute top-1/2 left-1/2 rounded-full border [border-color:var(--mise-light)]\"\n/** Rings that scale are a soft band, not a border: a hairline scaled down by the compositor breaks into dashes at 1x. */\nconst WAVE_CLASS = \"absolute top-1/2 left-1/2 rounded-full\"\nconst WAVE_IMAGE = \"radial-gradient(closest-side,transparent calc(100% - 7px),var(--mise-light) calc(100% - 3px),transparent 100%)\"\n\nfunction ringBox(sizePercent: number, opacity: number, extra?: React.CSSProperties): React.CSSProperties {\n  return { width: `${sizePercent}cqmax`, height: `${sizePercent}cqmax`, opacity, transform: CENTER, ...extra }\n}\n\nfunction StillRings({ count = 7, peak = 0.4 }: { count?: number; peak?: number }) {\n  const rings = useMemo(() => ringLayout(count, 10, 100, peak), [count, peak])\n  return rings.map((ring) => <span key={ring.size} data-slot=\"ripple-background-ring\" className={RING_CLASS} style={ringBox(ring.size, ring.opacity)} />)\n}\n\ninterface WavesProps {\n  still: boolean\n  count: number\n  size: number\n  seconds: number\n  peak: number\n}\n\nfunction Waves({ still, count, size, seconds, peak }: WavesProps) {\n  const waves = useMemo(() => waveLayout(count, seconds, peak), [count, seconds, peak])\n  return waves.map((wave) => (\n    <span\n      key={wave.delay}\n      data-slot=\"ripple-background-wave\"\n      className={cn(WAVE_CLASS, LOOP_CLASS)}\n      style={\n        still\n          ? ringBox(size, wave.stillOpacity, { backgroundImage: WAVE_IMAGE, transform: `${CENTER} scale(${wave.stillScale})` })\n          : { backgroundImage: WAVE_IMAGE, width: `${size}cqmax`, height: `${size}cqmax`, ...({ \"--mise-o\": peak } as React.CSSProperties), ...loopStyle(\"mise-ripple-spread\", seconds, wave.delay) }\n      }\n    />\n  ))\n}\n\nfunction SonarFace({ still }: { still: boolean }) {\n  return (\n    <>\n      <StillRings count={5} peak={0.3} />\n      <span\n        data-slot=\"ripple-background-sweep\"\n        className={cn(\"absolute top-1/2 left-1/2 size-[100cqmax] rounded-full\", LOOP_CLASS)}\n        style={{\n          backgroundImage: \"conic-gradient(from 0deg, transparent 0deg, transparent 270deg, var(--mise-light) 360deg)\",\n          opacity: SWEEP_OPACITY,\n          ...(still ? { transform: `${CENTER} rotate(115deg)` } : { transform: CENTER, ...loopStyle(\"mise-ripple-turn\", SONAR_SECONDS, -3) }),\n        }}\n      />\n    </>\n  )\n}\n\nfunction PondFace({ seed, still }: { seed: number; still: boolean }) {\n  const sources = useMemo(() => pondSources(seed, 3), [seed])\n  return sources.map((source) => (\n    <span key={source.left} data-slot=\"ripple-background-source\" className=\"absolute size-0\" style={{ left: `${source.left}%`, top: `${source.top}%` }}>\n      <Waves still={still} count={3} size={POND_SIZE} seconds={source.seconds} peak={POND_PEAK} />\n    </span>\n  ))\n}\n\nfunction RippleFace({ variant, seed, still }: { variant: RippleBackgroundVariant; seed: number; still: boolean }) {\n  if (variant === \"rings\") return <StillRings />\n  if (variant === \"pulse\") return <Waves still={still} count={4} size={100} seconds={PULSE_SECONDS} peak={PULSE_PEAK} />\n  if (variant === \"sonar\") return <SonarFace still={still} />\n  return <PondFace seed={seed} still={still} />\n}\n\nfunction usePressRings(active: boolean) {\n  const nextId = useRef(0)\n  const [rings, setRings] = useState<readonly PressRing[]>([])\n  const onPress = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!active || event.button !== 0) return\n    const root = event.currentTarget\n    const box = root.getBoundingClientRect()\n    nextId.current += 1\n    // clientLeft and clientTop: the layer sits inside the owner's border, the rect does not.\n    const ring = { id: nextId.current, x: event.clientX - box.left - root.clientLeft, y: event.clientY - box.top - root.clientTop }\n    setRings((current) => addPressRing(current, ring))\n  }\n  const onEnd = (id: number) => setRings((current) => current.filter((ring) => ring.id !== id))\n  return { rings, onPress, onEnd }\n}\n\ntype RippleBackgroundProps = Omit<React.ComponentProps<\"div\">, \"ref\"> & {\n  /** `rings` still concentric lines, `pulse` rings leaving the centre, `sonar` a sweep turning over rings, `pond` several sources. */\n  variant?: RippleBackgroundVariant\n  /** `press` spreads a ring from where the pointer or finger went down. It never takes the press from what is on top. */\n  effect?: RippleBackgroundEffect\n  /** Holds the looping motion. Wire it to a control: motion past five seconds needs a way to stop it. */\n  paused?: boolean\n  /** Another seed places the `pond` sources elsewhere, the same on the server and the client. */\n  seed?: number\n}\n\nfunction RippleBackground({ variant = \"rings\", effect, paused = false, seed = 1, className, children, onPointerDown, ...props }: RippleBackgroundProps) {\n  const rootRef = useRef<HTMLDivElement>(null)\n  const { still, playing } = useBackgroundPlayback(rootRef, { paused, loops: variant !== \"rings\" })\n  const pressing = effect === \"press\"\n  const { rings, onPress, onEnd } = usePressRings(pressing)\n  return (\n    <div\n      {...props}\n      ref={rootRef}\n      data-slot=\"ripple-background\"\n      data-variant={variant}\n      data-effect={effect}\n      data-playing={still ? undefined : String(playing)}\n      className={cn(\"relative isolate min-w-0 overflow-clip\", className)}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        onPress(event)\n      }}\n    >\n      {still ? null : <style href=\"mise-ripple-background\" precedence=\"default\">{KEYFRAMES}</style>}\n      {pressing ? <style href=\"mise-ripple-background-press\" precedence=\"default\">{PRESS_KEYFRAMES}</style> : null}\n      <div\n        aria-hidden=\"true\"\n        data-slot=\"ripple-background-layer\"\n        // A size container is safe here: the layer takes its size from inset, not from what is inside it.\n        className={cn(\"pointer-events-none absolute inset-0 -z-10 overflow-clip rounded-[inherit] [container-type:size]\", LIGHT_TOKEN_CLASS)}\n        style={playStyle(playing)}\n      >\n        <div data-slot=\"ripple-background-field\" className=\"absolute inset-0\" style={{ maskImage: MASK }}>\n          <RippleFace variant={variant} seed={seed} still={still} />\n        </div>\n        {rings.map((ring) => (\n          <span\n            key={ring.id}\n            data-slot=\"ripple-background-press\"\n            className={cn(WAVE_CLASS, \"size-[64cqmin]\")}\n            style={{ backgroundImage: WAVE_IMAGE, left: ring.x, top: ring.y, animation: `${still ? \"mise-ripple-press-still\" : \"mise-ripple-press\"} 900ms var(--ease-mise-out) forwards` }}\n            onAnimationEnd={() => onEnd(ring.id)}\n          />\n        ))}\n      </div>\n      <div data-slot=\"ripple-background-content\" className=\"relative h-full\">\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport { RippleBackground, type RippleBackgroundProps, type RippleBackgroundVariant, type RippleBackgroundEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/ripple-background-layout.ts",
      "content": "import { between, seededRandom } from \"@/registry/miseui/ui/background-seed\"\n\nconst MIN_SOURCE_GAP = 26\nconst PLACEMENT_TRIES = 24\n\nexport interface Ring {\n  /** Diameter in percent of the box's longer side. */\n  size: number\n  opacity: number\n}\n\n/**\n * The gaps widen on the way out, as a real ripple's do, which is what stops it reading as a target.\n * Opacity falls with distance, so the outer ring is already near nothing when the mask takes over.\n */\nexport function ringLayout(count: number, minSize: number, maxSize: number, peak: number): readonly Ring[] {\n  return Array.from({ length: count }, (_, index) => {\n    const position = count > 1 ? index / (count - 1) : 0\n    return {\n      size: Math.round((minSize + (maxSize - minSize) * position ** 1.25) * 100) / 100,\n      opacity: Math.round(peak * (1 - position * 0.8) * 1000) / 1000,\n    }\n  })\n}\n\nexport interface Wave {\n  /** Scale of the frozen frame, so the still picture looks like the moving one. */\n  stillScale: number\n  stillOpacity: number\n  delay: number\n}\n\n/** Evenly staggered: a ring leaves as another is halfway out. Negative delays mean the first frame is already full. */\nexport function waveLayout(count: number, seconds: number, peak: number): readonly Wave[] {\n  return Array.from({ length: count }, (_, index) => {\n    const position = (index + 0.6) / count\n    return {\n      stillScale: Math.round(position * 100) / 100,\n      stillOpacity: Math.round(peak * (1 - position) * 1000) / 1000,\n      delay: -Math.round((position * seconds * 100)) / 100,\n    }\n  })\n}\n\nexport interface Source {\n  left: number\n  top: number\n  seconds: number\n}\n\n/** Seeded places kept apart and away from the edge. Falls back to the last try so it always returns `count`. */\nexport function pondSources(seed: number, count: number): readonly Source[] {\n  const random = seededRandom(seed)\n  return Array.from({ length: count }).reduce<readonly Source[]>((placed) => {\n    const candidate = (): Source => ({ left: between(random, 14, 86), top: between(random, 16, 84), seconds: between(random, 11, 16) })\n    const clear = (source: Source) => placed.every((other) => Math.hypot(other.left - source.left, other.top - source.top) >= MIN_SOURCE_GAP)\n    const tries = Array.from({ length: PLACEMENT_TRIES }, candidate)\n    const chosen = tries.find(clear) ?? (tries[tries.length - 1] as Source)\n    return [...placed, chosen]\n  }, [])\n}\n\nexport interface PressRing {\n  id: number\n  x: number\n  y: number\n}\n\nconst MAX_PRESS_RINGS = 6\n\n/** Oldest goes first, so a fast tapper cannot pile up elements if an animation end never arrives in a hidden tab. */\nexport function addPressRing(rings: readonly PressRing[], ring: PressRing): readonly PressRing[] {\n  return [...rings, ring].slice(-MAX_PRESS_RINGS)\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-loop.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useState, useSyncExternalStore } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\n/**\n * The accent as a light source, held in a lightness band per theme. A near-black accent would read as smoke on a light\n * page and a dark blue would pull muted text under 4.5:1 where a beam crosses it, so the band's floor is 0.6.\n * Older browsers keep plain --mise, which is only a stronger tint.\n */\nexport const LIGHT_TOKEN_CLASS = [\n  \"[--mise-light:var(--mise)]\",\n  \"supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.6,l,0.76)_c_h)]\",\n  \"dark:supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.62,l,0.82)_c_h)]\",\n].join(\" \")\n\n/** Before hydration the hook cannot know the preference yet. A paused animation holds its seeded frame. */\nexport const LOOP_CLASS = \"motion-reduce:[animation-play-state:paused]!\"\n\nconst subscribeToVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange)\n  return () => document.removeEventListener(\"visibilitychange\", onChange)\n}\n\nfunction useTabVisible() {\n  return useSyncExternalStore(\n    subscribeToVisibility,\n    () => !document.hidden,\n    () => true,\n  )\n}\n\nfunction useOnScreen(rootRef: React.RefObject<HTMLElement | null>, watching: boolean) {\n  const [onScreen, setOnScreen] = useState(true)\n  useEffect(() => {\n    const root = rootRef.current\n    if (!watching || !root || typeof IntersectionObserver === \"undefined\") return\n    const view = new IntersectionObserver(([entry]) => setOnScreen(entry?.isIntersecting ?? true))\n    view.observe(root)\n    return () => view.disconnect()\n  }, [rootRef, watching])\n  return onScreen\n}\n\ninterface PlaybackOptions {\n  /** The owner's stop control. */\n  paused?: boolean\n  /** False when this look never moves, so nothing is watched at all. */\n  loops?: boolean\n}\n\n/** `still` draws one finished frame. `playing` is only true on screen, in a visible tab and not paused. */\nexport function useBackgroundPlayback(rootRef: React.RefObject<HTMLElement | null>, { paused = false, loops = true }: PlaybackOptions) {\n  const reduceMotion = useReducedMotion() ?? false\n  const moving = loops && !reduceMotion\n  const onScreen = useOnScreen(rootRef, moving)\n  const tabVisible = useTabVisible()\n  return { still: !moving, playing: moving && !paused && onScreen && tabVisible }\n}\n\n/** Longhands, because the shorthand would reset the play state each element reads from the layer. */\nexport function loopStyle(name: string, seconds: number, delay: number, timing = \"linear\", direction = \"normal\"): React.CSSProperties {\n  return {\n    animationName: name,\n    animationDuration: `${seconds}s`,\n    animationDelay: `${delay}s`,\n    animationTimingFunction: timing,\n    animationDirection: direction,\n    animationIterationCount: \"infinite\",\n    animationPlayState: \"var(--mise-bg-play)\",\n  }\n}\n\n/** Set once on the layer, so pausing writes one property however many elements are moving. */\nexport function playStyle(playing: boolean) {\n  return { \"--mise-bg-play\": playing ? \"running\" : \"paused\" } as React.CSSProperties\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-seed.ts",
      "content": "/** Mulberry32. Server and client walk the same numbers, so a seeded layout hydrates without a mismatch. */\nexport function seededRandom(seed: number) {\n  let state = seed >>> 0\n  return () => {\n    state = (state + 0x6d2b79f5) >>> 0\n    let mixed = state\n    mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)\n    mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)\n    return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n/** Two decimals: enough for a percent or a second, and short in the inline style. */\nexport function between(random: () => number, min: number, max: number) {\n  return Math.round((min + random() * (max - min)) * 100) / 100\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}