{
  "name": "shadow-text",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Shadow Text\n * @version: 1.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { type ElementType, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ShadowTextProps {\n  children: string;\n  as?: ElementType;\n  /**\n   * Light the letters like a sundial: the sun rises on the left at 6am,\n   * stands high at noon and sets on the right at 8pm, by the reader's clock.\n   * With false the light rests just after noon.\n   */\n  clock?: boolean;\n  /** Let the pointer take over as the light while it's near the text. */\n  interactive?: boolean;\n  /** The longest a shadow gets, in em. */\n  reach?: number;\n  className?: string;\n}\n\n// How close the pointer has to come (px around the text) to take the light.\nconst NEAR = 160;\n// Each frame the shadows close this much of the gap to where they should be.\nconst EASE = 0.14;\nconst MAX_STEPS = 40;\n\ninterface Sun {\n  /** Across the sky, 0 is far left and 1 far right. */\n  x: number;\n  /** 0 on the horizon, 1 straight overhead. */\n  height: number;\n  /** How strong the light is: full sun, or a little moonlight. */\n  strength: number;\n}\n\nfunction sunAt(date: Date): Sun {\n  const hours = date.getHours() + date.getMinutes() / 60;\n  const t = (hours - 6) / 14;\n  if (t < 0 || t > 1) {\n    // Night: a faint, high moon.\n    return { x: 0.5, height: 0.7, strength: 0.35 };\n  }\n  return { x: t, height: Math.sin(Math.PI * t), strength: 1 };\n}\n\n/** A long, soft shadow as one stack of text shadows. */\nfunction cast(dx: number, dy: number, strength: number) {\n  const length = Math.hypot(dx, dy);\n  if (length < 0.5 || strength < 0.01) {\n    return \"none\";\n  }\n  // Enough copies that they blur into one shadow instead of a comb.\n  const steps = Math.round(Math.min(MAX_STEPS, Math.max(6, length / 2.5)));\n  const spacing = length / steps;\n  const parts: string[] = [];\n  for (let i = 1; i <= steps; i++) {\n    const f = i / steps;\n    // Sharp where the letter meets its shadow, softer the farther it falls.\n    const blur = Math.max(spacing * 1.15, 0.3 + f * length * 0.08);\n    const alpha = strength * (3.6 / steps) * (1 - f) ** 1.6;\n    parts.push(\n      `${(dx * f).toFixed(1)}px ${(dy * f).toFixed(1)}px ${blur.toFixed(1)}px rgb(var(--shadow) / calc(var(--shadow-strength) * ${alpha.toFixed(3)}))`\n    );\n  }\n  return parts.join(\",\");\n}\n\nexport function ShadowText({\n  children,\n  as: Tag = \"p\",\n  clock = true,\n  interactive = true,\n  reach = 1.1,\n  className,\n}: ShadowTextProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const words = children.split(\" \");\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: children renders the letter spans the effect reads\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) {\n      return;\n    }\n    const letters = [...root.querySelectorAll<HTMLElement>(\"[data-letter]\")];\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    // Where each letter stands, and the shadow it casts now and should cast.\n    let centers: { x: number; y: number }[] = [];\n    let size = 16;\n    const now = letters.map(() => ({ x: 0, y: 0, s: 0 }));\n    const goal = letters.map(() => ({ x: 0, y: 0, s: 0 }));\n    let pointer: { x: number; y: number } | null = null;\n    let raf = 0;\n\n    const measure = () => {\n      const box = root.getBoundingClientRect();\n      size = Number.parseFloat(getComputedStyle(root).fontSize) || 16;\n      centers = letters.map((el) => {\n        const r = el.getBoundingClientRect();\n        return {\n          x: r.left - box.left + r.width / 2,\n          y: r.top - box.top + r.height * 0.6,\n        };\n      });\n    };\n\n    const aim = () => {\n      const box = root.getBoundingClientRect();\n      const longest = reach * size;\n      const sun = clock\n        ? sunAt(new Date())\n        : { x: 0.55, height: 0.85, strength: 1 };\n      for (let i = 0; i < letters.length; i++) {\n        const c = centers[i];\n        if (!c) {\n          continue;\n        }\n        if (pointer) {\n          // A lamp in your hand: the farther away, the longer the shadow.\n          const dx = c.x - pointer.x;\n          const dy = c.y - pointer.y;\n          const d = Math.max(1, Math.hypot(dx, dy));\n          const len = Math.min(longest, d * 0.22);\n          goal[i] = { x: (dx / d) * len, y: (dy / d) * len, s: 1 };\n        } else {\n          // The sun is far away, so every shadow points the same way; a low\n          // sun throws long ones.\n          const lx = box.width * (sun.x * 1.6 - 0.3);\n          const ly = -box.height * 3;\n          const dx = c.x - lx;\n          const dy = c.y - ly;\n          const d = Math.hypot(dx, dy);\n          const len = longest * (0.18 + (1 - sun.height) * 0.82);\n          goal[i] = { x: (dx / d) * len, y: (dy / d) * len, s: sun.strength };\n        }\n      }\n    };\n\n    const paint = () => {\n      for (let i = 0; i < letters.length; i++) {\n        letters[i].style.textShadow = cast(now[i].x, now[i].y, now[i].s);\n      }\n    };\n\n    const frame = () => {\n      raf = 0;\n      let moving = false;\n      for (let i = 0; i < letters.length; i++) {\n        const a = now[i];\n        const b = goal[i];\n        a.x += (b.x - a.x) * EASE;\n        a.y += (b.y - a.y) * EASE;\n        a.s += (b.s - a.s) * EASE;\n        if (\n          Math.abs(b.x - a.x) > 0.05 ||\n          Math.abs(b.y - a.y) > 0.05 ||\n          Math.abs(b.s - a.s) > 0.002\n        ) {\n          moving = true;\n        }\n      }\n      paint();\n      if (moving) {\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const update = () => {\n      aim();\n      if (reduceMotion) {\n        for (let i = 0; i < letters.length; i++) {\n          now[i] = { ...goal[i] };\n        }\n        paint();\n      } else if (!raf) {\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    measure();\n    update();\n\n    const resizeObserver = new ResizeObserver(() => {\n      measure();\n      update();\n    });\n    resizeObserver.observe(root);\n    document.fonts?.ready.then(() => {\n      measure();\n      update();\n    });\n    // The sun moves on with the clock.\n    const tick = window.setInterval(update, 30_000);\n\n    const onMove = (event: PointerEvent) => {\n      const box = root.getBoundingClientRect();\n      const x = event.clientX - box.left;\n      const y = event.clientY - box.top;\n      const near =\n        x > -NEAR && y > -NEAR && x < box.width + NEAR && y < box.height + NEAR;\n      if (near) {\n        pointer = { x, y };\n        update();\n      } else if (pointer) {\n        pointer = null;\n        update();\n      }\n    };\n    const onLeave = () => {\n      if (pointer) {\n        pointer = null;\n        update();\n      }\n    };\n    if (interactive) {\n      window.addEventListener(\"pointermove\", onMove);\n      document.documentElement.addEventListener(\"pointerleave\", onLeave);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      window.clearInterval(tick);\n      resizeObserver.disconnect();\n      window.removeEventListener(\"pointermove\", onMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [children, clock, interactive, reach]);\n\n  return (\n    <Tag\n      className={cn(\n        \"[--shadow-strength:1] [--shadow:24_24_27] dark:[--shadow-strength:1.7] dark:[--shadow:0_0_0]\",\n        className\n      )}\n      ref={rootRef}\n    >\n      <span className=\"sr-only\">{children}</span>\n      <span aria-hidden=\"true\">\n        {words.map((word, w) => (\n          <span key={`${word}-${w.toString()}`}>\n            <span className=\"inline-block whitespace-nowrap\">\n              {[...word].map((char, c) => (\n                <span\n                  className=\"inline-block\"\n                  data-letter=\"\"\n                  key={`${char}-${c.toString()}`}\n                >\n                  {char}\n                </span>\n              ))}\n            </span>\n            {w < words.length - 1 ? \" \" : null}\n          </span>\n        ))}\n      </span>\n    </Tag>\n  );\n}\n\nexport default function ShadowTextDemo() {\n  const [time, setTime] = useState<string | null>(null);\n\n  useEffect(() => {\n    const read = () =>\n      setTime(\n        new Date().toLocaleTimeString([], {\n          hour: \"numeric\",\n          minute: \"2-digit\",\n        })\n      );\n    read();\n    const id = window.setInterval(read, 30_000);\n    return () => window.clearInterval(id);\n  }, []);\n\n  return (\n    <div className=\"relative flex min-h-[460px] w-full flex-col items-center justify-center gap-6 overflow-hidden rounded-2xl bg-[#f2efe9] px-6 py-16 text-center dark:bg-zinc-900\">\n      <ShadowText\n        as=\"h2\"\n        className=\"max-w-3xl text-balance font-semibold text-5xl text-zinc-900 tracking-[-0.04em] sm:text-7xl dark:text-zinc-100\"\n      >\n        Golden hour, every day.\n      </ShadowText>\n      <p className=\"max-w-md text-balance text-base text-zinc-600 leading-relaxed dark:text-zinc-400\">\n        Lumen tells you when the light is best for photos near you, down to the\n        minute, and where to stand when it comes.\n      </p>\n      <a\n        className=\"inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 font-medium text-sm text-white transition-colors duration-150 hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300\"\n        href=\"#get\"\n      >\n        Get Lumen\n      </a>\n      <p className=\"absolute bottom-4 left-5 font-mono text-[11px] text-zinc-500 tabular-nums\">\n        {time ? `Shadows set for ${time}` : null}\n      </p>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/shadow-text.tsx",
      "target": "components/kokonutui/shadow-text.tsx"
    }
  ]
}