{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"feature-spotlight","type":"registry:component","title":"Feature spotlight","description":"A camera zooms into the part of the product screenshot each feature describes as you scroll past it.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/feature-spotlight.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Region = { x: number; y: number; w: number; h: number };\n\nexport type Feature = {\n  title: string;\n  body: string;\n  // Where this feature lives on the screenshot: the `data-spot` value of an\n  // element inside it, measured from the real layout so the spotlight can't\n  // drift from what it frames. For an <img>, give `region` instead, in the\n  // screenshot's own design pixels (see `screenshotSize`).\n  spot?: string;\n  region?: Region;\n};\n\n// Offsets ignore transforms, so this reads design pixels however far the\n// screenshot is scaled or zoomed. `root` must be positioned so the offset\n// chain ends at it.\nfunction measureSpot(root: HTMLElement, spot: string): Region | null {\n  const el = root.querySelector<HTMLElement>(`[data-spot=\"${spot}\"]`);\n  if (!el) return null;\n  let x = 0;\n  let y = 0;\n  let node: HTMLElement | null = el;\n  while (node && node !== root) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent as HTMLElement | null;\n  }\n  return { x, y, w: el.offsetWidth, h: el.offsetHeight };\n}\n\n// Camera moves are explanatory, not UI feedback: slow enough that the eye can\n// follow the pan and see where on the product it lands.\nconst CAMERA = { type: \"spring\", duration: 0.8, bounce: 0 } as const;\n// Room kept around a region when the camera frames it, in design pixels.\nconst PAD = 18;\n// Past this the screenshot turns into a blurry blow-up of a few words.\nconst MAX_ZOOM = 2.2;\n// How far above the first feature, or below the last, the reading line may\n// wander before the camera pulls back to the whole screenshot.\nconst CATCH = 28;\nconst HOLE_RADIUS = 12;\n\ntype ScrollRoot = React.RefObject<HTMLElement | null> | \"window\";\n\nexport function FeatureSpotlight({\n  features,\n  screenshot,\n  screenshotSize,\n  scrollRoot = \"window\",\n  className,\n}: {\n  features: Feature[];\n  // The product UI (or an <img>), drawn at `screenshotSize` design pixels\n  // and scaled to fit. Build it big with real type sizes, so zoomed in it\n  // stays crisp.\n  screenshot: React.ReactNode;\n  screenshotSize: { width: number; height: number };\n  // What scrolls the section: the window on a real page, or an element\n  // (like the demo's page mock) passed as a ref.\n  scrollRoot?: ScrollRoot;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const [active, setActive] = useState<number | null>(null);\n  const listRef = useRef<HTMLOListElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const textRefs = useRef<(HTMLDivElement | null)[]>([]);\n  // A click scrolls the feature into place; until that scroll settles the\n  // passing features shouldn't steal the camera.\n  const lockedUntil = useRef(0);\n  const [stickyTop, setStickyTop] = useState(24);\n\n  // Reads layout on scroll and only sets state when the focused feature\n  // changes, so scrolling doesn't re-render per frame.\n  useEffect(() => {\n    const target: HTMLElement | Window =\n      scrollRoot === \"window\" ? window : (scrollRoot.current ?? window);\n    let frame = 0;\n\n    const viewport = () =>\n      target === window\n        ? { top: 0, bottom: window.innerHeight }\n        : (target as HTMLElement).getBoundingClientRect();\n\n    const measure = () => {\n      frame = 0;\n      if (performance.now() < lockedUntil.current) return;\n      const view = viewport();\n      const list = listRef.current?.getBoundingClientRect();\n      const stage = stageRef.current?.getBoundingClientRect();\n      if (!list || !stage) return;\n      // Side by side, the reading line is the middle of the view. Stacked,\n      // the screenshot pins to the top, so it's the middle of what's left.\n      const stacked = stage.left < list.right - 1;\n      const top = stacked ? Math.max(view.top, stage.bottom) : view.top;\n      const line = (top + view.bottom) / 2;\n      // Inside the list the nearest feature always holds the camera, so\n      // the gaps between features never drop back to the overview; only\n      // before the first and after the last does it pull back.\n      let found: number | null = null;\n      let best = Infinity;\n      const els = textRefs.current;\n      els.forEach((el, i) => {\n        if (!el) return;\n        const r = el.getBoundingClientRect();\n        const d =\n          line < r.top ? r.top - line : line > r.bottom ? line - r.bottom : 0;\n        const outside =\n          (i === 0 && line < r.top - CATCH) ||\n          (i === els.length - 1 && line > r.bottom + CATCH);\n        if (!outside && d < best) {\n          best = d;\n          found = i;\n        }\n      });\n      setActive(found);\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(measure);\n    };\n    const onResize = () => {\n      const view = viewport();\n      const stage = stageRef.current;\n      if (stage) {\n        // Centres the pinned screenshot in the view when side by side.\n        setStickyTop(\n          Math.max(16, (view.bottom - view.top - stage.offsetHeight) / 2),\n        );\n      }\n      onScroll();\n    };\n\n    onResize();\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onResize);\n    const ro = new ResizeObserver(onResize);\n    if (target !== window) ro.observe(target as HTMLElement);\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onResize);\n      ro.disconnect();\n    };\n  }, [scrollRoot]);\n\n  const select = (i: number, now: number) => {\n    const el = textRefs.current[i];\n    const root = scrollRoot === \"window\" ? null : scrollRoot.current;\n    if (!el) return;\n    setActive(i);\n    const view = root\n      ? root.getBoundingClientRect()\n      : { top: 0, bottom: window.innerHeight };\n    const list = listRef.current?.getBoundingClientRect();\n    const stage = stageRef.current?.getBoundingClientRect();\n    const stacked = !!list && !!stage && stage.left < list.right - 1;\n    // Where the pinned screenshot will sit once scrolled, not where it is now.\n    const top = stacked ? view.top + (stage?.height ?? 0) : view.top;\n    const line = (top + view.bottom) / 2;\n    const r = el.getBoundingClientRect();\n    const delta = r.top + r.height / 2 - line;\n    lockedUntil.current = now + (reduceMotion ? 50 : 900);\n    const behavior: ScrollBehavior = reduceMotion ? \"auto\" : \"smooth\";\n    if (root) root.scrollBy({ top: delta, behavior });\n    else window.scrollBy({ top: delta, behavior });\n  };\n\n  return (\n    // Laid out by its own width, not the viewport's, so it works in a\n    // column as well as full bleed.\n    // Positioned, so the sr-only live region below stays inside whatever\n    // scrolls the section instead of escaping to the page and stretching it.\n    // The bottom padding is where the pinned screenshot comes to rest, so it\n    // stops clear of whatever follows the section instead of touching it.\n    <section className={cn(\"@container relative w-full pb-10\", className)}>\n      <div className=\"grid grid-cols-1 gap-x-7 @[34rem]:grid-cols-[minmax(0,1fr)_minmax(0,360px)]\">\n        <div\n          className=\"sticky top-0 z-10 bg-background pt-3 pb-4 @[34rem]:order-2 @[34rem]:self-start @[34rem]:bg-transparent @[34rem]:p-0 @[34rem]:[top:var(--sticky-top)]\"\n          style={{ \"--sticky-top\": `${stickyTop}px` } as React.CSSProperties}\n        >\n          <div ref={stageRef}>\n            <Stage\n              size={screenshotSize}\n              feature={active === null ? null : features[active]}\n              reduceMotion={reduceMotion}\n            >\n              {screenshot}\n            </Stage>\n          </div>\n        </div>\n\n        <ol ref={listRef} className=\"flex flex-col @[34rem]:order-1\">\n          {features.map((feature, i) => {\n            const on = active === i;\n            return (\n              // Just enough air to read one feature at a time. The last gets a\n              // little more below; the page's own footer supplies the rest it\n              // needs to reach the reading line.\n              <li\n                key={feature.title}\n                className=\"py-8 first:pt-4 last:pb-16 @[34rem]:first:pt-6\"\n              >\n                <div ref={(el) => void (textRefs.current[i] = el)}>\n                  <button\n                    type=\"button\"\n                    // Event time shares performance.now()'s clock.\n                    onClick={(e) => select(i, e.timeStamp)}\n                    aria-pressed={on}\n                    className=\"group relative flex w-full touch-manipulation flex-col items-start gap-2 rounded-xl py-1 pl-4 text-left outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground active:scale-[0.96]\"\n                  >\n                    {/* A rail beside each feature: the lit one is what the\n                        screenshot is showing. */}\n                    <span\n                      aria-hidden\n                      className=\"absolute top-1 bottom-1 left-0 w-0.5 overflow-hidden rounded-full bg-border\"\n                    >\n                      <span\n                        className={cn(\n                          \"block size-full origin-top rounded-full bg-foreground transition-[scale] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n                          on ? \"scale-y-100\" : \"scale-y-0\",\n                        )}\n                      />\n                    </span>\n                    <span\n                      className={cn(\n                        \"text-[13px] font-medium tabular-nums transition-colors duration-300 ease-out\",\n                        on ? \"text-foreground\" : \"text-muted-foreground\",\n                      )}\n                    >\n                      {String(i + 1).padStart(2, \"0\")}\n                    </span>\n                    <span\n                      className={cn(\n                        \"text-[17px] leading-snug font-semibold text-balance transition-colors duration-300 ease-out\",\n                        on\n                          ? \"text-foreground\"\n                          : \"text-muted-foreground group-hover:text-foreground\",\n                      )}\n                    >\n                      {feature.title}\n                    </span>\n                    <span\n                      className={cn(\n                        \"text-[14px] leading-relaxed text-pretty text-muted-foreground transition-opacity duration-300 ease-out\",\n                        on ? \"opacity-100\" : \"opacity-60\",\n                      )}\n                    >\n                      {feature.body}\n                    </span>\n                  </button>\n                </div>\n              </li>\n            );\n          })}\n        </ol>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {active === null ? \"\" : `Showing: ${features[active].title}`}\n      </p>\n    </section>\n  );\n}\n\nfunction Stage({\n  size,\n  feature,\n  reduceMotion,\n  children,\n}: {\n  size: { width: number; height: number };\n  feature: Feature | null;\n  reduceMotion: boolean;\n  children: React.ReactNode;\n}) {\n  const viewRef = useRef<HTMLDivElement>(null);\n  const shotRef = useRef<HTMLDivElement>(null);\n  const [fit, setFit] = useState(0.5);\n  const camX = useMotionValue(0);\n  const camY = useMotionValue(0);\n  const camScale = useMotionValue(1);\n  const holeX = useMotionValue(0);\n  const holeY = useMotionValue(0);\n  const holeW = useMotionValue(size.width);\n  const holeH = useMotionValue(size.height);\n  const dim = useMotionValue(0);\n  const transform = useMotionTemplate`translate(${camX}px, ${camY}px) scale(${camScale})`;\n  const running = useRef<AnimationPlaybackControls[]>([]);\n\n  // The screenshot is drawn at its design size and scaled to the frame.\n  useLayoutEffect(() => {\n    const el = viewRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setFit(el.offsetWidth / size.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [size.width]);\n\n  useEffect(() => {\n    running.current.forEach((a) => a.stop());\n    const shot = shotRef.current;\n    const region =\n      feature && shot && feature.spot\n        ? measureSpot(shot, feature.spot)\n        : (feature?.region ?? null);\n    const W = size.width;\n    const H = size.height;\n    let zoom = 1;\n    let x = 0;\n    let y = 0;\n    let hole: Region = { x: 0, y: 0, w: W, h: H };\n    if (region) {\n      hole = {\n        x: region.x - 6,\n        y: region.y - 6,\n        w: region.w + 12,\n        h: region.h + 12,\n      };\n      zoom = Math.min(\n        W / (region.w + PAD * 2),\n        H / (region.h + PAD * 2),\n        MAX_ZOOM,\n      );\n      // Centre the region, but never pan past the screenshot's edges.\n      const cx = region.x + region.w / 2;\n      const cy = region.y + region.h / 2;\n      x = Math.min(0, Math.max(W - W * zoom, W / 2 - cx * zoom));\n      y = Math.min(0, Math.max(H - H * zoom, H / 2 - cy * zoom));\n    }\n    const pairs: [typeof camX, number][] = [\n      [camX, x * fit],\n      [camY, y * fit],\n      [camScale, zoom],\n      [holeX, hole.x],\n      [holeY, hole.y],\n      [holeW, hole.w],\n      [holeH, hole.h],\n    ];\n    if (reduceMotion) {\n      // No camera travel: the spotlight alone points at the region.\n      pairs.forEach(([mv], i) => i > 2 && mv.jump(pairs[i][1]));\n      camX.jump(0);\n      camY.jump(0);\n      camScale.jump(1);\n      running.current = [animate(dim, region ? 1 : 0, { duration: 0.2 })];\n      return;\n    }\n    running.current = [\n      ...pairs.map(([mv, to]) => animate(mv, to, CAMERA)),\n      // The dim leads on the way in and trails on the way out, so the pull\n      // back reads as the room lights coming up after the camera leaves.\n      animate(dim, region ? 1 : 0, {\n        duration: region ? 0.35 : 0.5,\n        delay: region ? 0.15 : 0,\n        ease: [0.23, 1, 0.32, 1],\n      }),\n    ];\n  }, [\n    feature,\n    fit,\n    size.width,\n    size.height,\n    reduceMotion,\n    camX,\n    camY,\n    camScale,\n    holeX,\n    holeY,\n    holeW,\n    holeH,\n    dim,\n  ]);\n\n  useEffect(() => () => running.current.forEach((a) => a.stop()), []);\n\n  return (\n    <figure\n      aria-hidden\n      className=\"overflow-hidden rounded-[14px] bg-background shadow-raised\"\n    >\n      <div className=\"flex h-7 items-center gap-1.5 border-b border-border bg-muted px-3\">\n        <span className=\"size-2 rounded-full bg-border\" />\n        <span className=\"size-2 rounded-full bg-border\" />\n        <span className=\"size-2 rounded-full bg-border\" />\n      </div>\n      <div\n        ref={viewRef}\n        className=\"relative overflow-hidden\"\n        style={{ aspectRatio: `${size.width} / ${size.height}` }}\n      >\n        <motion.div\n          className=\"absolute top-0 left-0 origin-top-left\"\n          style={{ transform }}\n        >\n          <div\n            ref={shotRef}\n            // Positioned, so measured spots add their offsets up to here.\n            className=\"relative origin-top-left\"\n            style={{\n              width: size.width,\n              height: size.height,\n              scale: String(fit),\n            }}\n          >\n            {children}\n            {/* The spotlight: a rounded hole whose giant shadow dims the rest.\n                Sized with width and height rather than scale so its corners\n                stay round; it's one element, so the layout cost is small. */}\n            <motion.div\n              className=\"pointer-events-none absolute top-0 left-0\"\n              style={{\n                x: holeX,\n                y: holeY,\n                width: holeW,\n                height: holeH,\n                opacity: dim,\n                borderRadius: HOLE_RADIUS,\n                boxShadow:\n                  \"0 0 0 2px color-mix(in oklab, var(--foreground) 22%, transparent), 0 0 0 2000px color-mix(in oklab, var(--background) 70%, transparent)\",\n              }}\n            />\n          </div>\n        </motion.div>\n      </div>\n    </figure>\n  );\n}\n\n// The demo's product: a finance dashboard built at 640 x 440 design pixels\n// with real type sizes, then shown scaled down like a screenshot.\nconst SIZE = { width: 640, height: 440 };\n\nconst FEATURES: Feature[] = [\n  {\n    title: \"Your numbers before your coffee\",\n    body: \"Revenue, churn and payouts update the moment a charge settles, so the morning check takes one glance.\",\n    spot: \"metrics\",\n  },\n  {\n    title: \"Trends you can actually read\",\n    body: \"Ninety days of revenue on one line, with the dip on the 14th called out before anyone has to ask.\",\n    spot: \"revenue\",\n  },\n  {\n    title: \"Every payout, itemized\",\n    body: \"Each transfer lists the customer, the fees taken and when it lands, ready to reconcile in two clicks.\",\n    spot: \"payouts\",\n  },\n  {\n    title: \"A workspace for every entity\",\n    body: \"Run the parent company and both subsidiaries from one login, with books that never mix.\",\n    spot: \"workspaces\",\n  },\n];\n\nfunction Dashboard() {\n  // Plotted as data, not decoration: a 90 day revenue series.\n  const points = [\n    40, 44, 43, 48, 52, 50, 56, 58, 55, 38, 46, 57, 62, 60, 66, 70, 68, 74, 78,\n    76, 82, 88,\n  ];\n  const w = 430;\n  const h = 96;\n  const step = w / (points.length - 1);\n  const yOf = (v: number) => h - ((v - 30) / 60) * h;\n  const line = points\n    .map(\n      (v, i) => `${i ? \"L\" : \"M\"}${(i * step).toFixed(1)} ${yOf(v).toFixed(1)}`,\n    )\n    .join(\" \");\n  const area = `${line} L${w} ${h} L0 ${h} Z`;\n\n  return (\n    <div className=\"relative flex h-full w-full bg-background text-[13px] text-foreground\">\n      <aside className=\"flex w-[160px] shrink-0 flex-col gap-1 border-r border-border bg-muted p-3\">\n        <div data-spot=\"workspaces\" className=\"flex flex-col gap-1\">\n          <div className=\"flex h-11 items-center gap-2 rounded-lg bg-background px-2 shadow-raised\">\n            <span className=\"flex size-6 items-center justify-center rounded-md bg-foreground text-[12px] font-semibold text-background\">\n              A\n            </span>\n            <span className=\"flex-1 truncate font-medium\">Acme Inc</span>\n            <svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-3.5 text-muted-foreground\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n            >\n              <path d=\"M5 6.5 8 3.5l3 3M5 9.5l3 3 3-3\" />\n            </svg>\n          </div>\n          <p className=\"mt-3 px-2 text-[12px] text-muted-foreground\">Workspaces</p>\n          {[\"Acme Europe\", \"Acme Labs\", \"Add entity\"].map((name, i) => (\n            <div\n              key={name}\n              className=\"flex h-8 items-center gap-2 px-2 text-muted-foreground\"\n            >\n              <span\n                className={cn(\n                  \"size-4 rounded\",\n                  i === 2 ? \"border border-dashed border-muted-foreground\" : \"bg-border\",\n                )}\n              />\n              <span className=\"truncate\">{name}</span>\n            </div>\n          ))}\n        </div>\n        <div className=\"my-2 h-px bg-border\" />\n        {[\"Overview\", \"Payouts\", \"Customers\", \"Reports\"].map((name, i) => (\n          <div\n            key={name}\n            className={cn(\n              \"flex h-8 items-center rounded-md px-2\",\n              i === 0\n                ? \"bg-background font-medium shadow-raised\"\n                : \"text-muted-foreground\",\n            )}\n          >\n            {name}\n          </div>\n        ))}\n      </aside>\n\n      <div className=\"flex flex-1 flex-col gap-3 px-5 pt-4\">\n        <div className=\"flex h-8 items-center justify-between\">\n          <p className=\"text-[16px] font-semibold\">Overview</p>\n          <span className=\"rounded-full bg-muted px-3 py-1 text-[12px] text-muted-foreground\">\n            Last 90 days\n          </span>\n        </div>\n        <div data-spot=\"metrics\" className=\"grid grid-cols-3 gap-3\">\n          {[\n            [\"MRR\", \"$48.2k\", \"+4.1%\"],\n            [\"Churn\", \"1.8%\", \"−0.3%\"],\n            [\"Next payout\", \"$12.9k\", \"Fri\"],\n          ].map(([label, value, delta]) => (\n            <div\n              key={label}\n              className=\"flex h-[76px] flex-col justify-between rounded-lg bg-muted p-3\"\n            >\n              <span className=\"text-[12px] text-muted-foreground\">{label}</span>\n              <span className=\"flex items-baseline justify-between\">\n                <span className=\"text-[20px] font-semibold tracking-tight tabular-nums\">\n                  {value}\n                </span>\n                <span className=\"text-[12px] text-muted-foreground tabular-nums\">\n                  {delta}\n                </span>\n              </span>\n            </div>\n          ))}\n        </div>\n        <div\n          data-spot=\"revenue\"\n          className=\"flex h-[138px] flex-col gap-2 rounded-lg border border-border p-3\"\n        >\n          <div className=\"flex items-baseline justify-between\">\n            <span className=\"text-[12px] text-muted-foreground\">Revenue</span>\n            <span className=\"text-[12px] text-muted-foreground\">\n              Dip on Aug 14: failed card retries\n            </span>\n          </div>\n          <svg\n            viewBox={`0 0 ${w} ${h}`}\n            className=\"h-[96px] w-full overflow-visible\"\n            preserveAspectRatio=\"none\"\n          >\n            <path d={area} className=\"fill-foreground/[0.06]\" />\n            <path\n              d={line}\n              fill=\"none\"\n              className=\"stroke-foreground\"\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={9 * step}\n              cy={yOf(38)}\n              r={4}\n              className=\"fill-background stroke-marker\"\n              strokeWidth={2}\n            />\n          </svg>\n        </div>\n        <div data-spot=\"payouts\" className=\"flex flex-col\">\n          {[\n            [\"Northwind Traders\", \"Lands Fri\", \"$6,420.00\"],\n            [\"Globex Retail\", \"Lands Mon\", \"$4,180.50\"],\n            [\"Initech\", \"Settled\", \"$2,315.00\"],\n          ].map(([name, when, amount]) => (\n            <div\n              key={name}\n              className=\"flex h-9 items-center gap-3 border-b border-border last:border-0\"\n            >\n              <span className=\"flex-1 truncate font-medium\">{name}</span>\n              <span className=\"w-20 text-[12px] text-muted-foreground\">{when}</span>\n              <span className=\"w-24 text-right tabular-nums\">{amount}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default function FeatureSpotlightDemo() {\n  const pageRef = useRef<HTMLDivElement>(null);\n  return (\n    // A scrolling page mock, so the section works inside the lab. On a real\n    // site leave out `scrollRoot` and the window drives it.\n    <div\n      ref={pageRef}\n      className=\"h-[560px] w-[min(680px,100%)] overflow-y-auto overscroll-contain rounded-[20px] bg-background shadow-raised [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n    >\n      <div className=\"px-6\">\n        <header className=\"flex flex-col gap-3 pt-12 pb-4\">\n          <p className=\"text-[13px] font-medium text-muted-foreground\">\n            Ledgerly for finance teams\n          </p>\n          <h2 className=\"text-[28px] leading-tight font-semibold tracking-tight text-balance\">\n            Everything you check before the first meeting\n          </h2>\n          <p className=\"max-w-[46ch] text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n            Scroll through the four things teams open Ledgerly for. The\n            screenshot follows along.\n          </p>\n        </header>\n        <FeatureSpotlight\n          features={FEATURES}\n          screenshot={<Dashboard />}\n          screenshotSize={SIZE}\n          scrollRoot={pageRef}\n        />\n        <footer className=\"flex flex-col items-start gap-3 border-t border-border py-12\">\n          <p className=\"text-[17px] font-semibold\">Start a 14 day trial</p>\n          <p className=\"text-[14px] text-muted-foreground\">\n            No card needed. Import last year&apos;s books in minutes.\n          </p>\n        </footer>\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/feature-spotlight"}