{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "effect-02",
  "title": "Parallax card",
  "description": "A link card that leans away from the pointer, its surface shifting a few pixels and growing to 102% while the text and the picture that bleeds past its frame travel further, so the three read at different depths.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/sample-scenes.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/effect/effect-02.tsx",
      "content": "'use client';\n\nimport { useEffect, useImperativeHandle, useRef } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport { createFollower, type Follower } from './_parts/follower';\nimport { hundredthsOf } from './_parts/rounded';\nimport { useHover } from './_parts/use-hover';\n\ntype Layers = {\n  surface: HTMLElement;\n  media: HTMLElement;\n  text: HTMLElement;\n};\n\n/** Where the card leans from: the pointer across and down from its centre, each from -0.5 to 0.5, and how far it is lifted, from 0 to 1. */\ntype Lean = [across: number, down: number, lift: number];\n\nconst DEFAULT_DEPTH_PX = 12;\n\n/** Each layer moves this share of the depth: the surface least, the text on it a little more, the picture the whole depth. */\nconst SURFACE_SHARE = 0.25;\n\nconst TEXT_SHARE = 0.25;\n\nconst PICTURE_SHARE = 0.75;\n\nconst LIFT_SCALE = 0.02;\n\n/** A pointer is followed closely, a demonstration glides. */\nconst POINTER_STIFFNESS = 420;\n\nconst DEMO_STIFFNESS = 90;\n\nconst PRECISION = 0.004;\n\nconst LEAN_COUNT = 3;\n\nconst AT_REST: Lean = [0, 0, 0];\n\nfunction shiftOf(away: number, depth: number, share: number) {\n  const pixels = away * depth * share;\n  return hundredthsOf(pixels);\n}\n\nfunction moveLayer(\n  layer: HTMLElement,\n  awayX: number,\n  awayY: number,\n  depth: number,\n  share: number\n) {\n  const x = shiftOf(awayX, depth, share);\n  const y = shiftOf(awayY, depth, share);\n  layer.style.transform = `translate3d(${x}px, ${y}px, 0)`;\n}\n\nfunction isAtRest(values: readonly number[]) {\n  return values.every((value) => value === 0);\n}\n\nfunction clearLayers(layers: Layers) {\n  layers.surface.style.transform = '';\n  layers.media.style.transform = '';\n  layers.text.style.transform = '';\n}\n\n/** The card leans away from the pointer: every layer moves the other way, the deeper ones further. */\nfunction drawLean(layers: Layers, values: readonly number[], depth: number) {\n  if (isAtRest(values)) {\n    clearLayers(layers);\n    return;\n  }\n  const [across, down, lift] = values;\n  const awayX = -across * 2;\n  const awayY = -down * 2;\n  const surfaceX = shiftOf(awayX, depth, SURFACE_SHARE);\n  const surfaceY = shiftOf(awayY, depth, SURFACE_SHARE);\n  const scale = 1 + lift * LIFT_SCALE;\n  const roundedScale = Math.round(scale * 10000) / 10000;\n  layers.surface.style.transform = `translate3d(${surfaceX}px, ${surfaceY}px, 0) scale(${roundedScale})`;\n  moveLayer(layers.media, awayX, awayY, depth, PICTURE_SHARE);\n  moveLayer(layers.text, awayX, awayY, depth, TEXT_SHARE);\n}\n\nfunction withinCard(share: number) {\n  const atLeast = Math.max(-0.5, share);\n  return Math.min(0.5, atLeast);\n}\n\n/** The picture is larger than its frame by the furthest it travels, so its edge never shows. */\nfunction pictureStyleOf(picture: string, depth: number) {\n  const travel = Math.ceil(depth * PICTURE_SHARE);\n  const bleed = travel + 2;\n  return {\n    backgroundImage: `url(${picture})`,\n    inset: `${-bleed}px`,\n  };\n}\n\nexport type ParallaxCardHandle = {\n  /** Leans the card as if the pointer stood at this place, in shares of the card from its centre, -0.5 to 0.5. */\n  leanTowards: (across: number, down: number) => void;\n  rest: () => void;\n};\n\nexport type ParallaxCardProps = {\n  href: string;\n  title: string;\n  /** A short line above the title: what the card opens and how long it takes. */\n  kicker?: string;\n  /** The picture at the top of the card. It bleeds past the edges of its frame and travels the furthest. */\n  picture: string;\n  /** Who wrote it. Their picture is drawn from their name. */\n  author?: string;\n  /** How far the picture travels at the edge of the card, in pixels. The surface moves a quarter of it and the text half. */\n  depth?: number;\n  /** Leans the card from outside, for a demonstration. */\n  handleRef?: React.Ref<ParallaxCardHandle>;\n  className?: string;\n};\n\n/**\n * A card that leans away from the pointer: its surface shifts a few pixels and grows to 102% while\n * the text and the picture move further, so they read at three depths. One animation frame runs\n * while it moves and none at rest; it stays still under a finger and under reduced motion.\n */\nexport function ParallaxCard({\n  href,\n  title,\n  kicker,\n  picture,\n  author,\n  depth = DEFAULT_DEPTH_PX,\n  handleRef,\n  className,\n}: ParallaxCardProps) {\n  const surfaceRef = useRef<HTMLSpanElement>(null);\n  const mediaRef = useRef<HTMLSpanElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const follower = useRef<Follower | null>(null);\n  const focusLift = useRef(0);\n  const isStill = usePrefersReducedMotion();\n\n  useEffect(() => {\n    const surface = surfaceRef.current;\n    const media = mediaRef.current;\n    const text = textRef.current;\n    const hasSurface = surface !== null;\n    const hasMedia = media !== null;\n    const hasText = text !== null;\n    const hasLayers = hasSurface && hasMedia && hasText;\n    if (!hasLayers) {\n      return;\n    }\n    const layers: Layers = { surface, media, text };\n    const created = createFollower(\n      LEAN_COUNT,\n      (values) => drawLean(layers, values, depth),\n      { stiffness: POINTER_STIFFNESS, precision: PRECISION }\n    );\n    follower.current = created;\n    return () => {\n      created.stop();\n      clearLayers(layers);\n      follower.current = null;\n    };\n  }, [depth]);\n\n  useEffect(() => {\n    if (isStill) {\n      follower.current?.place(AT_REST);\n    }\n  }, [isStill]);\n\n  function lean(target: Lean, stiffness: number) {\n    if (isStill) {\n      return;\n    }\n    follower.current?.setStiffness(stiffness);\n    follower.current?.follow(target);\n  }\n\n  function leanFromPointer(event: React.PointerEvent<HTMLAnchorElement>) {\n    if (event.pointerType === 'touch') {\n      return;\n    }\n    const box = event.currentTarget.getBoundingClientRect();\n    const hasNoWidth = box.width === 0;\n    const hasNoHeight = box.height === 0;\n    if (hasNoWidth || hasNoHeight) {\n      return;\n    }\n    const across = (event.clientX - box.left) / box.width - 0.5;\n    const down = (event.clientY - box.top) / box.height - 0.5;\n    lean([withinCard(across), withinCard(down), 1], POINTER_STIFFNESS);\n  }\n\n  function settle() {\n    lean([0, 0, focusLift.current], POINTER_STIFFNESS);\n  }\n\n  function liftOnKeyboardFocus(event: React.FocusEvent<HTMLAnchorElement>) {\n    if (!event.target.matches(':focus-visible')) {\n      return;\n    }\n    focusLift.current = 1;\n    lean([0, 0, 1], POINTER_STIFFNESS);\n  }\n\n  function dropFocusLift() {\n    focusLift.current = 0;\n    lean(AT_REST, POINTER_STIFFNESS);\n  }\n\n  useImperativeHandle(handleRef, () => ({\n    leanTowards(across: number, down: number) {\n      lean([withinCard(across), withinCard(down), 1], DEMO_STIFFNESS);\n    },\n    rest() {\n      lean([0, 0, focusLift.current], DEMO_STIFFNESS);\n    },\n  }));\n\n  return (\n    <a\n      href={href}\n      onPointerMove={leanFromPointer}\n      onPointerLeave={settle}\n      onFocus={liftOnKeyboardFocus}\n      onBlur={dropFocusLift}\n      className={cn(\n        'focus-visible:ring-ring/25 focus-visible:ring-offset-background block w-full max-w-[18rem] rounded-xl transition-[scale] duration-150 ease-(--ease-out-strong) outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 active:scale-[0.98]',\n        className\n      )}\n    >\n      <span\n        ref={surfaceRef}\n        className='bg-card hairline-ring ring-border flex flex-col gap-3 rounded-xl p-1.5 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n      >\n        <span className='relative block aspect-[16/10] overflow-hidden rounded-[calc(var(--radius-xl)-0.375rem)]'>\n          <span\n            ref={mediaRef}\n            aria-hidden='true'\n            style={pictureStyleOf(picture, depth)}\n            className='absolute bg-cover bg-center'\n          />\n        </span>\n        <span ref={textRef} className='flex flex-col gap-1 px-2 pb-2'>\n          {kicker && (\n            <span className='text-muted-foreground text-xs'>{kicker}</span>\n          )}\n          <span className='text-sm leading-snug font-medium text-balance'>\n            {title}\n          </span>\n          {author && (\n            <span className='flex min-w-0 items-center gap-2 pt-2'>\n              <Avatar seed={author} className='size-5' />\n              <span className='text-muted-foreground truncate text-xs'>\n                {author}\n              </span>\n            </span>\n          )}\n        </span>\n      </span>\n    </a>\n  );\n}\n\nexport default function Effect02() {\n  const card = useRef<ParallaxCardHandle>(null);\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = [\n    { after: 1100, run: () => card.current?.leanTowards(-0.44, -0.38) },\n    { after: 1300, run: () => card.current?.leanTowards(0.42, -0.22) },\n    { after: 1200, run: () => card.current?.leanTowards(0.3, 0.4) },\n    { after: 1200, run: () => card.current?.rest() },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 1600,\n    onReset: () => card.current?.rest(),\n    isEnabled: !isHovered,\n  });\n  const isDemoStopped = !isAutoplaying && !isHovered;\n\n  /** A lean the demonstration leaves when a person takes over settles back: a keyboard has no pointer to undo it. */\n  useEffect(() => {\n    if (isDemoStopped) {\n      card.current?.rest();\n    }\n  }, [isDemoStopped]);\n\n  return (\n    <div ref={ref} className='flex w-full justify-center' {...hoverHandlers}>\n      <ParallaxCard\n        handleRef={card}\n        href='#'\n        kicker='Travel journal · 6 min read'\n        title='Walking the coast road at first light'\n        picture='/images/scenes/scene-08.svg'\n        author='Mara Lindqvist'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/effect/effect-02.tsx"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/follower.ts",
      "content": "/** A frame that comes late, after a long task, moves the values no further than this would. */\nconst MAX_ELAPSED_S = 0.05;\n\n/** The spring is worked out in steps this short, so a stiff spring stays steady on a slow frame. */\nconst STEP_S = 1 / 120;\n\nexport type FollowerOptions = {\n  /** How hard each value is pulled to its target: about 120 glides, 700 trails a pointer closely. */\n  stiffness: number;\n  /** How close to its target a value counts as arrived, in its own unit. */\n  precision: number;\n};\n\nexport type Follower = {\n  /** Sends the values towards new targets. Frames run until every value has arrived, then stop. */\n  follow: (targets: readonly number[]) => void;\n  /** Puts the values on new targets at once and draws them, with no movement. */\n  place: (targets: readonly number[]) => void;\n  setStiffness: (stiffness: number) => void;\n  /** Asks for no more frames. The values stay where they are. */\n  stop: () => void;\n};\n\n/**\n * Values that follow their targets like a spring that does not bounce. Each frame hands them to\n * `draw`, which writes them to the page; no frame is asked for once they have all arrived.\n */\nexport function createFollower(\n  count: number,\n  draw: (values: readonly number[]) => void,\n  options: FollowerOptions\n): Follower {\n  const values = new Array<number>(count).fill(0);\n  const speeds = new Array<number>(count).fill(0);\n  const targets = new Array<number>(count).fill(0);\n  const { precision } = options;\n  let stiffness = options.stiffness;\n  let frame = 0;\n  let lastAt = 0;\n\n  function hasArrived() {\n    const settledSpeed = precision * Math.sqrt(stiffness) * 2;\n    for (let index = 0; index < count; index += 1) {\n      const gap = Math.abs(targets[index] - values[index]);\n      const speed = Math.abs(speeds[index]);\n      const isMoving = gap > precision || speed > settledSpeed;\n      if (isMoving) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  function land() {\n    for (let index = 0; index < count; index += 1) {\n      values[index] = targets[index];\n      speeds[index] = 0;\n    }\n  }\n\n  function advance(seconds: number) {\n    const damping = 2 * Math.sqrt(stiffness);\n    for (let index = 0; index < count; index += 1) {\n      const gap = targets[index] - values[index];\n      const force = stiffness * gap - damping * speeds[index];\n      speeds[index] += force * seconds;\n      values[index] += speeds[index] * seconds;\n    }\n  }\n\n  function tick(now: number) {\n    frame = 0;\n    const elapsedMs = now - lastAt;\n    lastAt = now;\n    const forward = Math.max(0, elapsedMs / 1000);\n    let left = Math.min(forward, MAX_ELAPSED_S);\n    while (left > 0) {\n      const step = Math.min(left, STEP_S);\n      advance(step);\n      left -= step;\n    }\n    if (hasArrived()) {\n      land();\n      draw(values);\n      return;\n    }\n    draw(values);\n    frame = requestAnimationFrame(tick);\n  }\n\n  function wake() {\n    if (frame !== 0) {\n      return;\n    }\n    lastAt = performance.now();\n    frame = requestAnimationFrame(tick);\n  }\n\n  function follow(next: readonly number[]) {\n    for (let index = 0; index < count; index += 1) {\n      targets[index] = next[index] ?? 0;\n    }\n    wake();\n  }\n\n  function stop() {\n    cancelAnimationFrame(frame);\n    frame = 0;\n  }\n\n  function place(next: readonly number[]) {\n    stop();\n    for (let index = 0; index < count; index += 1) {\n      targets[index] = next[index] ?? 0;\n    }\n    land();\n    draw(values);\n  }\n\n  function setStiffness(next: number) {\n    stiffness = next;\n  }\n\n  return { follow, place, setStiffness, stop };\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/follower.ts"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/rounded.ts",
      "content": "/**\n * Two engines can disagree on the last digit of a sine, and a frame writes many positions: a value\n * written into a style is rounded to the hundredth, so the server and the browser print the same\n * text and the style stays short.\n */\nexport function hundredthsOf(value: number) {\n  const hundredths = Math.round(value * 100);\n  return hundredths / 100;\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/rounded.ts"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/use-hover.ts"
    }
  ],
  "categories": [
    "components",
    "effect"
  ],
  "type": "registry:component"
}