{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sidebar-highlight","type":"registry:component","title":"Sidebar highlight","description":"A dot travels to whatever you pick and takes on its section colour, which then wipes across the label.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/sidebar-highlight.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue } from \"motion/react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Gustavo's sidebar active state (https://x.com/heyimgustavo): one dot\n// travels to whatever you pick, taking on its section's colour on the way,\n// the label steps aside for it, and the colour wipes across the label.\n\nexport type SidebarSection = {\n  title: string;\n  // The section's hue. Any CSS colour, light-dark() included.\n  tone: string;\n  icon: React.ReactNode;\n  items: { label: string; isNew?: boolean }[];\n};\n\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n// Quick enough to keep up with clicking down the list, soft enough that the\n// dot is seen travelling rather than teleporting.\nconst TRAVEL = { type: \"spring\", visualDuration: 0.35, bounce: 0.15 } as const;\nconst DOT = 6;\n\nexport function SidebarHighlight({\n  sections,\n  defaultActive,\n  onSelect,\n  className,\n}: {\n  sections: SidebarSection[];\n  defaultActive?: string;\n  onSelect?: (label: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const [own, setOwn] = useState(\n    defaultActive ?? sections[0]?.items[0]?.label,\n  );\n  // The index card clicks around on its own state.\n  const [show, setShow] = useState(own);\n  const active = play ? show : own;\n\n  const listRef = useRef<HTMLDivElement>(null);\n  const items = useRef(new Map<string, HTMLButtonElement>());\n  const y = useMotionValue(0);\n  const placed = useRef(false);\n\n  const tone = sections.find((s) => s.items.some((i) => i.label === active))\n    ?.tone;\n\n  // The dot lives in the list, not in an item, so it can travel between\n  // them. It lines up with the middle of the active row.\n  useLayoutEffect(() => {\n    const place = (instant: boolean) => {\n      const item = active ? items.current.get(active) : undefined;\n      if (!item) return;\n      const target = item.offsetTop + item.offsetHeight / 2 - DOT / 2;\n      if (instant || reduceMotion) y.jump(target);\n      else animate(y, target, TRAVEL);\n    };\n    place(!placed.current);\n    placed.current = true;\n    // Fonts landing or the width changing moves the rows; follow without\n    // animating. An observer reports once as soon as it starts, which would\n    // snap the dot mid-travel, so only a real change in size counts.\n    const list = listRef.current;\n    if (!list) return;\n    let size = `${list.offsetWidth}x${list.offsetHeight}`;\n    const observer = new ResizeObserver(() => {\n      const next = `${list.offsetWidth}x${list.offsetHeight}`;\n      if (next === size) return;\n      size = next;\n      place(true);\n    });\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [active, reduceMotion, y]);\n\n  // The card's show: someone reading down the list, picking a note now and\n  // then.\n  useEffect(() => {\n    if (!play) return;\n    const picks = [\"Letter spacing\", \"Novelty budget\", \"Tabular numbers\"];\n    let i = 0;\n    const timer = setInterval(() => {\n      setShow(picks[i % picks.length]);\n      i += 1;\n    }, 1100);\n    return () => {\n      clearInterval(timer);\n      setShow(own);\n    };\n  }, [play, own]);\n\n  return (\n    <nav\n      aria-label=\"Notes\"\n      // Rows fade at the top and bottom edges, so the list reads as going on\n      // rather than being cut off.\n      className={cn(\n        \"h-[440px] w-[300px] max-w-full overflow-y-auto overscroll-contain rounded-3xl bg-background shadow-raised [mask-image:linear-gradient(to_bottom,transparent,black_36px,black_calc(100%-36px),transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n        className,\n      )}\n    >\n      <div ref={listRef} className=\"relative px-7 py-8\">\n        <motion.span\n          aria-hidden\n          className=\"absolute top-0 left-7 size-1.5 rounded-full transition-[background-color] duration-300 ease-out motion-reduce:transition-none\"\n          style={{ y, backgroundColor: tone }}\n        />\n        {sections.map((section) => (\n          <section key={section.title} className=\"mt-7 first:mt-0\">\n            <h3\n              className=\"mb-1.5 flex items-center gap-2.5 text-[15px] font-medium\"\n              style={{ \"--tone\": section.tone } as React.CSSProperties}\n            >\n              <span className=\"size-4 text-(--tone)\">{section.icon}</span>\n              {section.title}\n            </h3>\n            <ul>\n              {section.items.map(({ label, isNew }) => {\n                const on = label === active;\n                return (\n                  <li key={label}>\n                    <button\n                      ref={(el) => {\n                        if (el) items.current.set(label, el);\n                        else items.current.delete(label);\n                      }}\n                      type=\"button\"\n                      aria-current={on ? \"true\" : undefined}\n                      onClick={() => {\n                        setOwn(label);\n                        onSelect?.(label);\n                      }}\n                      className=\"group/item flex h-8 w-full items-center rounded-md text-left text-[15px] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n                      style={{ \"--tone\": section.tone } as React.CSSProperties}\n                    >\n                      {/* Steps right to make room for the dot. */}\n                      <span\n                        className={cn(\n                          \"flex items-center gap-2 transition-[translate] duration-300 motion-reduce:transition-none\",\n                          on && \"translate-x-3.5\",\n                        )}\n                        style={{ transitionTimingFunction: EASE_OUT }}\n                      >\n                        <span className=\"relative whitespace-nowrap\">\n                          <span className=\"text-muted-foreground transition-[color] duration-150 ease-out group-hover/item:text-foreground\">\n                            {label}\n                          </span>\n                          {/* The section colour wipes in left to right on\n                              a copy of the label laid over it, so the\n                              hues never mix mid-way. */}\n                          <span\n                            aria-hidden\n                            className=\"absolute inset-0 text-(--tone) transition-[clip-path] motion-reduce:transition-none\"\n                            style={{\n                              clipPath: on ? \"inset(0 0 0 0)\" : \"inset(0 100% 0 0)\",\n                              transitionDuration: on ? \"450ms\" : \"200ms\",\n                              transitionTimingFunction: EASE_OUT,\n                            }}\n                          >\n                            {label}\n                          </span>\n                        </span>\n                        {isNew && (\n                          <span className=\"rounded-full bg-green-500/12 px-1.5 py-px text-[11px] font-medium text-green-700 ring-1 ring-green-500/25 ring-inset dark:text-green-400\">\n                            New\n                          </span>\n                        )}\n                      </span>\n                    </button>\n                  </li>\n                );\n              })}\n            </ul>\n          </section>\n        ))}\n      </div>\n    </nav>\n  );\n}\n\nconst ICON = {\n  viewBox: \"0 0 16 16\",\n  className: \"size-4\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n  \"aria-hidden\": true,\n} as const;\n\n// Section hues are data: each section keeps its own, a step lighter in dark\n// mode so it holds the same contrast.\nconst SECTIONS: SidebarSection[] = [\n  {\n    title: \"Craft\",\n    tone: \"light-dark(oklch(0.62 0.17 45), oklch(0.74 0.15 50))\",\n    icon: (\n      <svg {...ICON}>\n        <path d=\"M3 13l1.5-4.5L11 2l3 3-6.5 6.5L3 13Z\" />\n        <path d=\"M9.5 3.5l3 3\" />\n      </svg>\n    ),\n    items: [\n      { label: \"Taste is trained\" },\n      { label: \"Novelty budget\" },\n      { label: \"Details compound\" },\n    ],\n  },\n  {\n    title: \"Typography\",\n    tone: \"light-dark(oklch(0.55 0.18 260), oklch(0.72 0.14 255))\",\n    icon: (\n      <svg {...ICON}>\n        <path d=\"M1.75 12.5 5 3.5l3.25 9M2.9 9.5h4.2\" />\n        <circle cx=\"11.75\" cy=\"10.25\" r=\"2.25\" />\n        <path d=\"M14 8v4.5\" />\n      </svg>\n    ),\n    items: [\n      { label: \"Letter spacing\" },\n      { label: \"Text wrapping\" },\n      { label: \"Tabular numbers\", isNew: true },\n      { label: \"Optical sizes\" },\n    ],\n  },\n  {\n    title: \"Color\",\n    tone: \"light-dark(oklch(0.58 0.2 10), oklch(0.72 0.16 10))\",\n    icon: (\n      <svg {...ICON}>\n        <circle cx=\"8\" cy=\"5.25\" r=\"2.5\" />\n        <circle cx=\"5\" cy=\"10.5\" r=\"2.5\" />\n        <circle cx=\"11\" cy=\"10.5\" r=\"2.5\" />\n      </svg>\n    ),\n    items: [\n      { label: \"OKLCH\" },\n      { label: \"Grain\", isNew: true },\n      { label: \"Shadows over borders\" },\n      { label: \"Image outlines\" },\n    ],\n  },\n  {\n    title: \"Layout\",\n    tone: \"light-dark(oklch(0.66 0.16 70), oklch(0.78 0.14 75))\",\n    icon: (\n      <svg {...ICON}>\n        <rect x=\"2.25\" y=\"3\" width=\"11.5\" height=\"10\" rx=\"1.75\" />\n        <path d=\"M6.5 3v10M6.5 7h7.25\" />\n      </svg>\n    ),\n    items: [\n      { label: \"Concentric radii\", isNew: true },\n      { label: \"Hit areas\" },\n      { label: \"Clip paths\" },\n      { label: \"Scroll fades\" },\n    ],\n  },\n  {\n    title: \"Motion\",\n    tone: \"light-dark(oklch(0.55 0.2 300), oklch(0.72 0.15 300))\",\n    icon: (\n      <svg {...ICON}>\n        <path d=\"M3 12.5C7 12.5 8 3.5 13 3.5\" />\n        <circle cx=\"3\" cy=\"12.5\" r=\"1.25\" />\n        <circle cx=\"13\" cy=\"3.5\" r=\"1.25\" />\n      </svg>\n    ),\n    items: [\n      { label: \"Icon morphs\" },\n      { label: \"Button press\" },\n      { label: \"Easing curves\" },\n      { label: \"Stagger\" },\n      { label: \"Interruptibility\" },\n    ],\n  },\n];\n\nexport default function SidebarHighlightDemo() {\n  return <SidebarHighlight sections={SECTIONS} defaultActive=\"Tabular numbers\" />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\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":{"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":{"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":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/sidebar-highlight"}