{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "service-ledger", "type": "registry:ui", "title": "Service Ledger", "description": "A scroll-spy services section with a sticky tab strip at the top. As the reader scrolls past each long-form panel (title, description, deliverables, optional image, inline CTA), the matching tab highlights; clicking a tab smooth-scrolls to that entry.", "dependencies": [ "lucide-react" ], "files": [ { "path": "registry/ruixenui/service-ledger.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport Link from \"next/link\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── types ───────────────────────────────────────────────────── */\n\nexport interface ServiceEntry {\n /** Short tag/code shown in the tab (e.g. \"S01\", \"Strategy\"). */\n code: string;\n /** Small metadata shown next to the code — duration, pricing tier, etc. */\n meta?: string;\n /** Main service title */\n title: string;\n /** Service summary */\n description: string;\n /** Deliverables / what's included */\n items?: string[];\n /** Optional preview image src */\n image?: string;\n /** Optional preview video src — takes priority over `image` when both are provided */\n video?: string;\n /** Optional poster image shown before the video plays */\n poster?: string;\n /** Optional CTA — rendered as an inline arrow link below the entry */\n cta?: {\n href: string;\n label: string;\n };\n}\n\nexport interface ServiceLedgerProps {\n title?: string;\n description?: string;\n entries?: ServiceEntry[];\n className?: string;\n}\n\n/* ── defaults ────────────────────────────────────────────────── */\n\nconst defaultServices: ServiceEntry[] = [\n {\n code: \"S01\",\n meta: \"4–6 weeks\",\n title: \"Brand Foundation Sprint\",\n description:\n \"Discovery, positioning, and a tight brand system to anchor every surface — from the homepage to the next funding deck.\",\n items: [\n \"Stakeholder & audience interviews\",\n \"Positioning, voice, and messaging matrix\",\n \"Logo system, type stack, and core color tokens\",\n \"Brand guidelines doc + Figma library\",\n ],\n cta: { href: \"#\", label: \"See the playbook\" },\n },\n {\n code: \"S02\",\n meta: \"8–12 weeks\",\n title: \"Design System Engineering\",\n description:\n \"A token-driven, accessibility-audited component library your engineers can actually ship with — built on shadcn primitives.\",\n items: [\n \"Foundations: tokens, spacing scale, type ramp\",\n \"Primitive + composite component coverage\",\n \"Theming hooks for light/dark and brand variants\",\n \"Storybook + MDX docs your team can search\",\n ],\n },\n {\n code: \"S03\",\n meta: \"12–16 weeks\",\n title: \"Product Build-Out\",\n description:\n \"Full-stack delivery for the first slice of your product — designed, built, instrumented, and live in a quarter.\",\n items: [\n \"Next.js app router + edge-ready deploys\",\n \"Auth, billing, and data layer wiring\",\n \"Analytics, error tracking, and feature flags\",\n \"Two-week launch checklist + handoff\",\n ],\n cta: { href: \"#\", label: \"Read a case study\" },\n },\n {\n code: \"S04\",\n meta: \"Ongoing\",\n title: \"Growth & Optimization\",\n description:\n \"Embedded design + engineering capacity for the months after launch — landing pages, experiments, and lifecycle UI.\",\n items: [\n \"Quarterly site refreshes and net-new pages\",\n \"Experiment briefs and A/B test scaffolding\",\n \"Onboarding, pricing, and upgrade flows\",\n \"Weekly working sessions with your team\",\n ],\n },\n];\n\n/* ── component ───────────────────────────────────────────────── */\n\nexport function ServiceLedger({\n title = \"How we work with you\",\n description = \"Four ways our team plugs into your roadmap — from a single sprint to a year-long partnership.\",\n entries = defaultServices,\n className,\n}: ServiceLedgerProps) {\n const [activeIndex, setActiveIndex] = React.useState(0);\n const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false);\n const sectionRef = React.useRef(null);\n const entryRefs = React.useRef>([]);\n const tabsRef = React.useRef(null);\n\n /* Honor the visitor's reduced-motion preference. Resolve matchMedia from the\n section's ownerWindow so it stays correct when the component is portaled\n into a docs-preview iframe. Drives smooth-scroll fallbacks and video\n autoplay below. */\n React.useEffect(() => {\n const ownerWindow =\n sectionRef.current?.ownerDocument?.defaultView ?? window;\n const mq = ownerWindow.matchMedia(\"(prefers-reduced-motion: reduce)\");\n const update = () => setPrefersReducedMotion(mq.matches);\n update();\n mq.addEventListener(\"change\", update);\n return () => mq.removeEventListener(\"change\", update);\n }, []);\n\n /* Scroll-spy. Two things to get right here:\n\n 1. WHICH document scrolls. When the component is portaled into an\n iframe (docs previews use IFramePortal), the React effect runs in\n the OUTER JS context but the DOM lives in the iframe. The global\n `document` is the wrong target — we have to resolve the actual\n document via the section's ownerDocument. We then listen in the\n capture phase so scroll events from any nested scroll container\n inside that document are caught (scroll events don't bubble but\n they DO traverse capture).\n\n 2. WHERE the reference line is. We derive it from the strip's actual\n rendered bottom (getBoundingClientRect().bottom + 20). That makes\n the spy robust against in-page navbars, CSS-variable sticky\n offsets, and varying strip heights — no hardcoded magic number. */\n React.useEffect(() => {\n let raf: number | null = null;\n const GAP = 20;\n const FALLBACK_REFERENCE_Y = 100;\n\n const compute = () => {\n raf = null;\n const strip = tabsRef.current;\n const stripBottom = strip?.getBoundingClientRect().bottom ?? 0;\n const referenceY =\n stripBottom > 0 ? stripBottom + GAP : FALLBACK_REFERENCE_Y;\n\n // Publish the live strip height so the panels' scroll-margin-top (used by\n // tab clicks and #hash navigation) lands an entry's top on this exact\n // same reference line. If the two drift, the clicked tab isn't the one\n // that ends up highlighted.\n if (strip && sectionRef.current) {\n sectionRef.current.style.setProperty(\n \"--ledger-strip-h\",\n `${strip.offsetHeight}px`,\n );\n }\n\n let next = 0;\n for (let i = 0; i < entryRefs.current.length; i++) {\n const el = entryRefs.current[i];\n if (!el) continue;\n const rect = el.getBoundingClientRect();\n if (rect.top - referenceY <= 1) {\n next = i;\n } else {\n break;\n }\n }\n\n // Near the bottom of the scroll range the last entry's top may never\n // reach the reference line (not enough content below it), so force the\n // final tab once we've effectively bottomed out.\n const doc = sectionRef.current?.ownerDocument ?? document;\n const scroller = doc.scrollingElement ?? doc.documentElement;\n if (\n scroller &&\n scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2\n ) {\n next = entryRefs.current.length - 1;\n }\n\n setActiveIndex((prev) => (prev === next ? prev : next));\n };\n\n const onScroll = () => {\n if (raf == null) raf = requestAnimationFrame(compute);\n };\n\n compute();\n\n const ownerDoc = sectionRef.current?.ownerDocument ?? document;\n const ownerWindow = ownerDoc.defaultView ?? window;\n\n ownerDoc.addEventListener(\"scroll\", onScroll, {\n passive: true,\n capture: true,\n });\n ownerWindow.addEventListener(\"resize\", onScroll, { passive: true });\n\n return () => {\n ownerDoc.removeEventListener(\"scroll\", onScroll, true);\n ownerWindow.removeEventListener(\"resize\", onScroll);\n if (raf != null) cancelAnimationFrame(raf);\n };\n }, [entries.length]);\n\n /* Keep the active tab visible inside the horizontal scroller. Scroll the\n strip horizontally only — scrollIntoView can nudge the page vertically and\n fight the click/scroll-spy, landing the reader on the wrong panel. */\n React.useEffect(() => {\n const container = tabsRef.current;\n const tab = container?.children[activeIndex] as HTMLElement | undefined;\n if (!container || !tab) return;\n const cRect = container.getBoundingClientRect();\n const tRect = tab.getBoundingClientRect();\n const delta =\n tRect.left - cRect.left - (container.clientWidth - tab.clientWidth) / 2;\n container.scrollBy({\n left: delta,\n behavior: prefersReducedMotion ? \"auto\" : \"smooth\",\n });\n }, [activeIndex, prefersReducedMotion]);\n\n const handleTabClick = (\n event: React.MouseEvent,\n index: number,\n ) => {\n event.preventDefault();\n const target = entryRefs.current[index];\n if (target) {\n target.scrollIntoView({\n behavior: prefersReducedMotion ? \"auto\" : \"smooth\",\n block: \"start\",\n });\n }\n };\n\n return (\n
\n {/* ── header ──────────────────────────────────────── */}\n
\n
\n

\n {title}\n

\n

\n {description}\n

\n
\n
\n\n {/* ── sticky tab strip ────────────────────────────── */}\n \n
\n
\n \n {entries.map((entry, i) => {\n const isActive = activeIndex === i;\n return (\n handleTabClick(event, i)}\n aria-current={isActive ? \"true\" : undefined}\n className={cn(\n \"-mb-px inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-3.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-4 sm:py-4\",\n isActive\n ? \"border-foreground text-foreground\"\n : \"border-transparent text-muted-foreground hover:text-foreground\",\n )}\n >\n {entry.code}\n {entry.meta && (\n \n {entry.meta}\n \n )}\n \n );\n })}\n
\n
\n \n \n\n {/* ── content panels ──────────────────────────────── */}\n
\n
\n {entries.map((entry, index) => (\n {\n entryRefs.current[index] = el;\n }}\n style={{\n scrollMarginTop:\n \"calc(var(--ledger-sticky-top, 0px) + var(--ledger-strip-h, 4rem) + 20px)\",\n }}\n className=\"flex flex-col\"\n >\n

\n {entry.title}\n

\n

\n {entry.description}\n

\n\n {entry.items && entry.items.length > 0 && (\n
    \n {entry.items.map((item, itemIndex) => (\n
  • \n {item}\n
  • \n ))}\n
\n )}\n\n {entry.video ? (\n \n ) : entry.image ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : null}\n\n {entry.cta && (\n \n {entry.cta.label}\n \n \n )}\n
\n ))}\n
\n \n
\n );\n}\n\nexport default ServiceLedger;\n", "type": "registry:ui", "target": "components/ruixen/service-ledger.tsx" } ] }