{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-trail",
  "title": "Image Trail",
  "description": "Pictures appear along the pointer's path and fade in order, pile up, fan out or slide past, with a still arrangement on touch so the box is never blank.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/image-trail.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useCallback, useEffect, useRef } from \"react\"\r\nimport { useReducedMotion } from \"motion/react\"\r\nimport { createTrailEngine, type TrailEngine } from \"@/registry/miseui/ui/image-trail-engine\"\r\nimport { POOL_SIZE, STILL_LAYOUT, type ImageTrailVariant } from \"@/registry/miseui/ui/image-trail-math\"\r\nimport { isFinePointer } from \"@/registry/miseui/ui/pointer-fine\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype ImageTrailEffect = \"speed\"\r\n\r\n/** The shape does half the work of telling the variants apart: portrait tiles, squares with a paper edge, tall cards, wide frames. */\r\nconst NODE_CLASS: Record<ImageTrailVariant, string> = {\r\n  trail: \"aspect-[4/5] rounded-md shadow-md\",\r\n  pile: \"aspect-square rounded-sm shadow-lg ring-4 ring-background\",\r\n  fan: \"aspect-[3/4] origin-bottom rounded-lg shadow-lg\",\r\n  strip: \"aspect-[8/5] rounded-sm ring-1 ring-foreground/10\",\r\n}\r\n\r\ntype ImageTrailProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\r\n  /** What appears along the path: any nodes. They are decoration, hidden from assistive tech and from focus. */\r\n  items: readonly React.ReactNode[]\r\n  /** `trail` pop in and fade in order, `pile` stay stacked until the pointer rests, `fan` fall at seeded angles, `strip` upright frames sliding along the path. */\r\n  variant?: ImageTrailVariant\r\n  /** `speed` makes pictures larger the faster the pointer moves. */\r\n  effect?: ImageTrailEffect\r\n  /** Distance between pictures, in px. */\r\n  spacing?: number\r\n  /** Width of a picture, in px. */\r\n  size?: number\r\n  /** Words over the pictures. They stay on top, readable and selectable. */\r\n  children?: React.ReactNode\r\n}\r\n\r\ninterface TrailPartProps {\r\n  items: readonly React.ReactNode[]\r\n  variant: ImageTrailVariant\r\n  size: number\r\n}\r\n\r\n/** The rest state, on every device. It is the whole effect on touch and under reduced motion, where the box must not be blank. */\r\nfunction TrailStill({ items, variant, size }: TrailPartProps) {\r\n  return (\r\n    <div aria-hidden=\"true\" inert data-slot=\"image-trail-still\" className=\"pointer-events-none absolute inset-0 -z-10 transition-opacity duration-(--duration-slow) ease-(--ease-mise) select-none group-data-[active=true]/trail:opacity-0 motion-reduce:transition-none\">\r\n      {STILL_LAYOUT[variant].slice(0, items.length).map((place, index) => (\r\n        <div\r\n          key={index}\r\n          data-slot=\"image-trail-still-item\"\r\n          className={cn(\"absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden *:size-full\", NODE_CLASS[variant])}\r\n          style={{ left: `${place.left}%`, top: `${place.top}%`, rotate: `${place.rotate}deg`, width: size }}\r\n        >\r\n          {items[index]}\r\n        </div>\r\n      ))}\r\n    </div>\r\n  )\r\n}\r\n\r\n/** Rendered once. After that every node is moved by the engine, without React. */\r\nfunction TrailPool({ layerRef, items, variant, size }: TrailPartProps & { layerRef: React.RefObject<HTMLDivElement | null> }) {\r\n  const pool = items.length > 0 ? Array.from({ length: POOL_SIZE }, (_, index) => items[index % items.length]) : []\r\n  return (\r\n    <div ref={layerRef} aria-hidden=\"true\" inert data-slot=\"image-trail-layer\" className=\"pointer-events-none absolute inset-0 -z-10 select-none\">\r\n      {pool.map((item, index) => (\r\n        <div key={index} data-slot=\"image-trail-node\" className={cn(\"absolute top-0 left-0 overflow-hidden opacity-0 *:size-full\", NODE_CLASS[variant])} style={{ width: size }}>\r\n          {item}\r\n        </div>\r\n      ))}\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction ImageTrail({ items, variant = \"trail\", effect, spacing = 72, size = 96, className, children, onPointerMove, onPointerLeave, ...props }: ImageTrailProps) {\r\n  const rootRef = useRef<HTMLDivElement>(null)\r\n  const layerRef = useRef<HTMLDivElement>(null)\r\n  const engineRef = useRef<TrailEngine | null>(null)\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const speed = effect === \"speed\"\r\n\r\n  useEffect(() => {\r\n    const root = rootRef.current\r\n    const layer = layerRef.current\r\n    if (!root || !layer) return\r\n    const engine = createTrailEngine({ root, layer, variant, speed, spacing })\r\n    engineRef.current = engine\r\n    return () => {\r\n      engine.dispose()\r\n      engineRef.current = null\r\n    }\r\n  }, [speed, spacing, variant, items.length])\r\n\r\n  const move = useCallback(\r\n    (event: React.PointerEvent<HTMLDivElement>) => {\r\n      onPointerMove?.(event)\r\n      if (reduceMotion || event.pointerType === \"touch\" || !isFinePointer()) return\r\n      const box = event.currentTarget.getBoundingClientRect()\r\n      engineRef.current?.move({ x: event.clientX - box.left, y: event.clientY - box.top }, event.timeStamp)\r\n    },\r\n    [onPointerMove, reduceMotion],\r\n  )\r\n\r\n\r\n  return (\r\n    <div\r\n      ref={rootRef}\r\n      data-slot=\"image-trail\"\r\n      data-variant={variant}\r\n      data-effect={effect}\r\n      className={cn(\"group/trail relative isolate min-w-0 overflow-clip\", className)}\r\n      onPointerMove={move}\r\n      onPointerLeave={(event) => {\r\n        onPointerLeave?.(event)\r\n        engineRef.current?.leave()\r\n      }}\r\n      {...props}\r\n    >\r\n      <TrailStill items={items} variant={variant} size={size} />\r\n      <TrailPool layerRef={layerRef} items={items} variant={variant} size={size} />\r\n      {/* The soft edge keeps words legible where a picture passes behind them, without a panel that would hide the effect. */}\r\n      <div data-slot=\"image-trail-content\" className=\"relative h-full [text-shadow:0_0_2px_var(--background),0_0_6px_var(--background),0_0_6px_var(--background),0_0_14px_var(--background)]\">\r\n        {children}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { ImageTrail, type ImageTrailProps, type ImageTrailVariant, type ImageTrailEffect }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-trail-engine.ts",
      "content": "import {\n  clearAnimation,\n  pathDirection,\n  seededRotation,\n  spawnAnimation,\n  spawnPoints,\n  speedScale,\n  type ImageTrailVariant,\n  type Point,\n} from \"@/registry/miseui/ui/image-trail-math\"\n\nexport interface TrailEngineOptions {\n  root: HTMLElement\n  /** Holds the fixed pool of pictures. */\n  layer: HTMLElement\n  variant: ImageTrailVariant\n  speed: boolean\n  spacing: number\n}\n\nexport interface TrailEngine {\n  move: (point: Point, now: number) => void\n  leave: () => void\n  dispose: () => void\n}\n\n/** How long the pointer has to rest before a pile clears. */\nconst PILE_REST_MS = 450\n/** After this with no movement the still arrangement comes back. */\nconst IDLE_MS = 1500\n\n/** Everything here is direct writes and the Web Animations API. A spawn never touches React. */\nexport function createTrailEngine({ root, layer, variant, speed, spacing }: TrailEngineOptions): TrailEngine {\n  const nodes = [...layer.children] as HTMLElement[]\n  const running = new Map<HTMLElement, Animation>()\n  const piled = new Map<HTMLElement, string>()\n  const state = {\n    cursor: 0,\n    spawned: 0,\n    anchor: null as Point | null,\n    lastPoint: null as Point | null,\n    lastAt: 0,\n    restTimer: undefined as ReturnType<typeof setTimeout> | undefined,\n    idleTimer: undefined as ReturnType<typeof setTimeout> | undefined,\n  }\n\n  const play = (node: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions) => {\n    running.get(node)?.cancel()\n    running.set(node, node.animate(keyframes, options))\n  }\n\n  const spawn = (point: Point, direction: Point, scale: number) => {\n    const node = nodes[state.cursor]\n    if (!node) return\n    state.cursor = (state.cursor + 1) % nodes.length\n    state.spawned += 1\n    const x = point.x - node.offsetWidth / 2\n    const y = point.y - node.offsetHeight / 2\n    const rotation = seededRotation(variant, state.spawned)\n    const { keyframes, options } = spawnAnimation(variant, { x, y, scale, rotation, direction })\n    // Removed first so a reused picture rejoins the pile on top, in the order it will clear.\n    piled.delete(node)\n    if (variant === \"pile\") piled.set(node, keyframes[keyframes.length - 1]?.transform as string)\n    play(node, keyframes, options)\n    root.dataset.active = \"true\"\n  }\n\n  const clearPile = () => {\n    [...piled.entries()].forEach(([node, rest], order) => {\n      const { keyframes, options } = clearAnimation(rest, order)\n      play(node, keyframes, options)\n    })\n    piled.clear()\n  }\n\n  const wait = () => {\n    clearTimeout(state.restTimer)\n    clearTimeout(state.idleTimer)\n    if (variant === \"pile\") state.restTimer = setTimeout(clearPile, PILE_REST_MS)\n    state.idleTimer = setTimeout(() => {\n      delete root.dataset.active\n    }, IDLE_MS)\n  }\n\n  const move = (point: Point, now: number) => {\n    const elapsed = now - state.lastAt\n    const travelled = state.lastPoint ? Math.hypot(point.x - state.lastPoint.x, point.y - state.lastPoint.y) : 0\n    const scale = speed && elapsed > 0 ? speedScale(travelled / elapsed) : 1\n    const direction = state.lastPoint ? pathDirection(state.lastPoint, point) : { x: 1, y: 0 }\n    state.lastPoint = point\n    state.lastAt = now\n    if (!state.anchor) {\n      state.anchor = point\n      return\n    }\n    const step = spawnPoints(state.anchor, point, spacing)\n    state.anchor = step.anchor\n    step.points.forEach((spot) => spawn(spot, direction, scale))\n    wait()\n  }\n\n  const leave = () => {\n    state.anchor = null\n    state.lastPoint = null\n    wait()\n  }\n\n  const dispose = () => {\n    clearTimeout(state.restTimer)\n    clearTimeout(state.idleTimer)\n    running.forEach((animation) => animation.cancel())\n    running.clear()\n    piled.clear()\n    delete root.dataset.active\n  }\n\n  return { move, leave, dispose }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-trail-math.ts",
      "content": "import { between, seededRandom } from \"@/registry/miseui/ui/background-seed\"\r\n\r\nexport type ImageTrailVariant = \"trail\" | \"pile\" | \"fan\" | \"strip\"\r\n\r\nexport interface Point {\r\n  x: number\r\n  y: number\r\n}\r\n\r\n/** A fixed pool: a fast pointer reuses the oldest picture instead of growing the page. */\r\nexport const POOL_SIZE = 12\r\n/** A pointer that jumps across the box in one event should leave a few pictures, not every one in the pool. */\r\nconst MAX_SPAWNS_PER_EVENT = 3\r\n\r\n/**\r\n * Spawning by distance keeps the spacing even at any speed; by time it would bunch up when slow and gap when fast.\r\n * Points fall along the path from the last spawn, and the leftover distance stays with the anchor.\r\n */\r\nexport function spawnPoints(anchor: Point, point: Point, spacing: number): { points: readonly Point[]; anchor: Point } {\r\n  const distance = Math.hypot(point.x - anchor.x, point.y - anchor.y)\r\n  if (spacing <= 0 || distance < spacing) return { points: [], anchor }\r\n  const count = Math.floor(distance / spacing)\r\n  const unit = pathDirection(anchor, point)\r\n  const at = (step: number) => ({ x: anchor.x + unit.x * spacing * step, y: anchor.y + unit.y * spacing * step })\r\n  const first = Math.max(1, count - MAX_SPAWNS_PER_EVENT + 1)\r\n  const points = Array.from({ length: count - first + 1 }, (_, index) => at(first + index))\r\n  return { points, anchor: at(count) }\r\n}\r\n\r\nexport function pathDirection(from: Point, to: Point): Point {\r\n  const length = Math.hypot(to.x - from.x, to.y - from.y)\r\n  return length === 0 ? { x: 1, y: 0 } : { x: (to.x - from.x) / length, y: (to.y - from.y) / length }\r\n}\r\n\r\n/** 0.8 for a slow drift, 1.5 for a fast sweep. Past about 2 px per ms a pointer is simply moving fast. */\r\nexport function speedScale(pxPerMs: number) {\r\n  const progress = Math.min(1, Math.max(0, pxPerMs / 2))\r\n  return Math.round((0.8 + progress * 0.7) * 100) / 100\r\n}\r\n\r\nconst ROTATION_RANGE: Record<ImageTrailVariant, number> = { trail: 4, pile: 8, fan: 18, strip: 0 }\r\n\r\n/** Seeded, so the same order of spawns always falls the same way and tests can read it. */\r\nexport function seededRotation(variant: ImageTrailVariant, index: number) {\r\n  const range = ROTATION_RANGE[variant]\r\n  if (range === 0) return 0\r\n  return between(seededRandom(index + 1), -range, range)\r\n}\r\n\r\ninterface SpawnInput {\r\n  x: number\r\n  y: number\r\n  scale: number\r\n  rotation: number\r\n  direction: Point\r\n}\r\n\r\ninterface SpawnAnimation {\r\n  keyframes: Keyframe[]\r\n  options: KeyframeAnimationOptions\r\n}\r\n\r\nconst transformAt = (x: number, y: number, rotation: number, scale: number) => `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation}deg) scale(${scale})`\r\n\r\nconst POP = \"cubic-bezier(0.16, 1, 0.3, 1)\"\r\n\r\n/** Each entry is the whole life of a picture. `pile` only arrives, and stays until the pointer rests. */\r\nexport function spawnAnimation(variant: ImageTrailVariant, { x, y, scale, rotation, direction }: SpawnInput): SpawnAnimation {\r\n  const fill = \"both\" as const\r\n  if (variant === \"pile\") {\r\n    return {\r\n      keyframes: [\r\n        { opacity: 0, transform: transformAt(x, y, rotation * 0.4, scale * 0.8), easing: POP },\r\n        { opacity: 1, transform: transformAt(x, y, rotation, scale) },\r\n      ],\r\n      options: { duration: 240, fill },\r\n    }\r\n  }\r\n  if (variant === \"fan\") {\r\n    return {\r\n      keyframes: [\r\n        { opacity: 0, offset: 0, transform: transformAt(x, y, 0, scale * 0.4), easing: POP },\r\n        { opacity: 1, offset: 0.22, transform: transformAt(x, y, rotation, scale) },\r\n        { opacity: 1, offset: 0.65, transform: transformAt(x, y, rotation, scale) },\r\n        { opacity: 0, offset: 1, transform: transformAt(x, y, rotation * 1.2, scale * 0.94) },\r\n      ],\r\n      options: { duration: 1200, fill },\r\n    }\r\n  }\r\n  if (variant === \"strip\") {\r\n    const before = { x: x - direction.x * 40, y: y - direction.y * 40 }\r\n    const after = { x: x + direction.x * 16, y: y + direction.y * 16 }\r\n    return {\r\n      keyframes: [\r\n        { opacity: 0, offset: 0, transform: transformAt(before.x, before.y, 0, scale), easing: POP },\r\n        { opacity: 1, offset: 0.3, transform: transformAt(x, y, 0, scale) },\r\n        { opacity: 1, offset: 0.6, transform: transformAt(x, y, 0, scale) },\r\n        { opacity: 0, offset: 1, transform: transformAt(after.x, after.y, 0, scale) },\r\n      ],\r\n      options: { duration: 1000, fill },\r\n    }\r\n  }\r\n  return {\r\n    keyframes: [\r\n      { opacity: 0, offset: 0, transform: transformAt(x, y, rotation, scale * 0.7), easing: POP },\r\n      { opacity: 1, offset: 0.16, transform: transformAt(x, y, rotation, scale) },\r\n      { opacity: 1, offset: 0.5, transform: transformAt(x, y, rotation, scale) },\r\n      { opacity: 0, offset: 1, transform: transformAt(x, y, rotation, scale * 0.9) },\r\n    ],\r\n    options: { duration: 900, fill },\r\n  }\r\n}\r\n\r\nexport function clearAnimation(rest: string, order: number): SpawnAnimation {\r\n  return {\r\n    keyframes: [\r\n      { opacity: 1, transform: rest },\r\n      { opacity: 0, transform: `${rest} scale(0.92)` },\r\n    ],\r\n    options: { duration: 240, delay: order * 30, fill: \"both\", easing: \"ease-out\" },\r\n  }\r\n}\r\n\r\nexport interface StillPlace {\r\n  left: number\r\n  top: number\r\n  rotate: number\r\n}\r\n\r\n/** Kept to the edges, so the words in the middle of the box stay clear. Each one previews how its variant behaves. */\r\nexport const STILL_LAYOUT: Record<ImageTrailVariant, readonly StillPlace[]> = {\r\n  trail: [\r\n    { left: 17, top: 21, rotate: -6 },\r\n    { left: 83, top: 20, rotate: 5 },\r\n    { left: 16, top: 80, rotate: 4 },\r\n    { left: 84, top: 79, rotate: -5 },\r\n  ],\r\n  pile: [\r\n    { left: 76, top: 74, rotate: -8 },\r\n    { left: 82, top: 72, rotate: 6 },\r\n    { left: 79, top: 78, rotate: -1 },\r\n  ],\r\n  fan: [\r\n    { left: 33, top: 88, rotate: -16 },\r\n    { left: 50, top: 84, rotate: 0 },\r\n    { left: 67, top: 88, rotate: 16 },\r\n  ],\r\n  strip: [\r\n    { left: 18, top: 84, rotate: 0 },\r\n    { left: 50, top: 84, rotate: 0 },\r\n    { left: 82, top: 84, rotate: 0 },\r\n  ],\r\n}\r\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"
    },
    {
      "path": "registry/miseui/ui/pointer-fine.ts",
      "content": "/**\n * Asked when an event arrives, never during render, so a server render hydrates and a laptop with a touch screen\n * answers for the input it is using right now.\n */\nexport function isFinePointer() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(hover: hover) and (pointer: fine)\").matches\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "interactions"
  ],
  "type": "registry:ui"
}