{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "layouts-scroll-header", "type": "registry:block", "title": "Scroll Header", "description": "Scroll-aware sticky header with fluid animated tab transitions. Ships with home, about, contact, and danger-zone demo pages.", "dependencies": [ "motion", "lucide-react", "next-themes" ], "registryDependencies": [ "skeleton" ], "files": [ { "path": "components/layouts/scroll-header/scroll-header.tsx", "content": "\"use client\";\n\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { ArrowLeftIcon, Sparkles } from \"lucide-react\";\nimport { motion, useScroll, useSpring, useTransform } from \"motion/react\";\n\nimport { ModeToggle } from \"@/components/mode-toggle\";\nimport { AnimatedTabs } from \"@/components/layouts/scroll-header/animated-tabs\";\n\nconst tabs = [\n { label: \"Home\", value: \"home\", href: \"/preview/layouts/scroll-header\" },\n {\n label: \"About\",\n value: \"about\",\n href: \"/preview/layouts/scroll-header/about\",\n },\n {\n label: \"Contact\",\n value: \"contact\",\n href: \"/preview/layouts/scroll-header/contact\",\n },\n {\n label: \"Danger Zone\",\n value: \"danger-zone\",\n href: \"/preview/layouts/scroll-header/danger-zone\",\n },\n];\n\n// Spring profile tuned for scroll-linked motion: tight, near-critical damping,\n// low mass. Responds quickly, settles cleanly, no visible overshoot.\nconst SCROLL_SPRING = { stiffness: 500, damping: 50, mass: 0.5 } as const;\n\nexport function ScrollHeader() {\n // Read the window scroll position as a motion value (rAF-driven,\n // passive listener, no React state, no re-renders per frame).\n const { scrollY } = useScroll();\n\n // Logo shrink: scale 1 → 0.8 over the first 33px of scroll.\n const logoScaleRaw = useTransform(scrollY, [0, 33], [1, 0.8], {\n clamp: true,\n });\n const logoScale = useSpring(logoScaleRaw, SCROLL_SPRING);\n\n // Tab strip nudges right 0 → 40px over the first 80px of scroll to\n // clear space for the shrunken logo corner.\n const tabXRaw = useTransform(scrollY, [0, 80], [0, 40], { clamp: true });\n const tabX = useSpring(tabXRaw, SCROLL_SPRING);\n\n return (\n <>\n
\n \n
\n \n
\n \n\n
\n
\n \n \n Layouts\n \n /\n Scroll Header\n
\n
\n \n
\n
\n
\n\n
\n
\n \n \n \n
\n
\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/scroll-header/scroll-header.tsx" }, { "path": "components/layouts/scroll-header/animated-tabs.tsx", "content": "\"use client\";\n\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\n\nimport useTabs, { type Tab } from \"@/hooks/layouts/use-tabs\";\nimport { cn } from \"@/lib/utils\";\n\ninterface AnimatedTabsProps {\n tabs: Tab[];\n}\n\nconst transition = {\n type: \"tween\",\n ease: \"easeOut\",\n duration: 0.15,\n};\n\nconst getHoverAnimationProps = (hoveredRect: DOMRect, navRect: DOMRect) => ({\n x: hoveredRect.left - navRect.left - 10,\n y: hoveredRect.top - navRect.top - 4,\n width: hoveredRect.width + 20,\n height: hoveredRect.height + 10,\n});\n\nconst Tabs = ({\n tabs,\n selectedTabIndex,\n setSelectedTab,\n}: {\n tabs: Tab[];\n selectedTabIndex: number;\n setSelectedTab: (input: [number, number]) => void;\n}) => {\n const [buttonRefs, setButtonRefs] = React.useState<\n Array\n >([]);\n\n React.useEffect(() => {\n setButtonRefs((prev) => prev.slice(0, tabs.length));\n }, [tabs.length]);\n\n const navRef = React.useRef(null);\n const navRect = navRef.current?.getBoundingClientRect();\n\n const selectedRect = buttonRefs[selectedTabIndex]?.getBoundingClientRect();\n\n const [hoveredTabIndex, setHoveredTabIndex] = React.useState(\n null,\n );\n const hoveredRect =\n buttonRefs[hoveredTabIndex ?? -1]?.getBoundingClientRect();\n\n return (\n setHoveredTabIndex(null)}\n >\n {tabs.map((item, i) => {\n const isActive = selectedTabIndex === i;\n return (\n setHoveredTabIndex(i)}\n onFocus={() => setHoveredTabIndex(i)}\n onClick={() => setSelectedTab([i, i > selectedTabIndex ? 1 : -1])}\n >\n {\n buttonRefs[i] = el as HTMLAnchorElement;\n }}\n className={cn(\"block text-sm\", {\n \"text-zinc-500\": !isActive,\n \"font-semibold text-black dark:text-white\": isActive,\n })}\n >\n \n {item.label}\n \n \n \n );\n })}\n\n \n {hoveredRect && navRect && (\n value === \"danger-zone\")\n ? \"bg-red-100 dark:bg-red-500/30\"\n : \"bg-zinc-100 dark:bg-zinc-800\"\n }`}\n initial={{\n ...getHoverAnimationProps(hoveredRect, navRect),\n opacity: 0,\n }}\n animate={{\n ...getHoverAnimationProps(hoveredRect, navRect),\n opacity: 1,\n }}\n exit={{\n ...getHoverAnimationProps(hoveredRect, navRect),\n opacity: 0,\n }}\n transition={transition as Transition}\n />\n )}\n \n\n \n {selectedRect && navRect && (\n value === \"danger-zone\")\n ? \"bg-red-500\"\n : \"bg-black dark:bg-white\"\n }`}\n initial={false}\n animate={{\n width: selectedRect.width + 18,\n x: `calc(${selectedRect.left - navRect.left - 9}px)`,\n opacity: 1,\n }}\n transition={transition as Transition}\n />\n )}\n \n \n );\n};\n\nexport function AnimatedTabs({ tabs }: AnimatedTabsProps) {\n const pathname = usePathname();\n\n const [hookProps] = React.useState(() => {\n const matchedTab =\n tabs.find((tab) => tab.href && pathname?.startsWith(tab.href)) ?? tabs[0];\n return {\n tabs: tabs.map(({ label, value, subRoutes, href }) => ({\n label,\n value,\n subRoutes,\n href,\n })),\n initialTabId: matchedTab.value,\n };\n });\n\n const framer = useTabs(hookProps);\n\n return (\n
\n \n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/scroll-header/animated-tabs.tsx" }, { "path": "components/layouts/scroll-header/demo-skeleton.tsx", "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\n/**\n * Placeholder body content for the ScrollHeader demo pages. The demo's focus\n * is the scroll-linked header animation — real copy would distract from that,\n * so every tab page renders skeleton blocks of enough total height to make\n * the scroll interaction feel real.\n */\nexport function DemoSkeleton() {\n return (\n
\n
\n
\n {/* Hero */}\n
\n \n \n \n
\n\n {/* Two-column paragraph block */}\n
\n {Array.from({ length: 2 }).map((_, i) => (\n
\n \n \n \n \n \n
\n ))}\n
\n\n {/* Card block */}\n
\n \n \n \n \n
\n {Array.from({ length: 4 }).map((_, i) => (\n \n ))}\n
\n
\n\n {/* Feature grid */}\n
\n {Array.from({ length: 6 }).map((_, i) => (\n \n \n \n \n \n
\n ))}\n
\n\n {/* CTA row */}\n
\n \n \n
\n \n \n
\n
\n
\n
\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/scroll-header/demo-skeleton.tsx" }, { "path": "components/layouts/scroll-header/footer.tsx", "content": "import Link from \"next/link\";\nimport { Button } from \"@/components/ui/button\";\n\nconst footerLinks = [\n { label: \"Next.js\", href: \"https://nextjs.org/\" },\n { label: \"Tailwind CSS\", href: \"https://tailwindcss.com/\" },\n { label: \"Shadcn/ui\", href: \"https://ui.shadcn.com/\" },\n { label: \"Motion\", href: \"https://motion.dev/\" },\n];\n\nexport function ScrollHeaderFooter() {\n return (\n
\n

\n Scroll-aware navigation with fluid tab transitions. ✨\n

\n
    \n {footerLinks.map((link) => (\n
  • \n \n \n {link.label}\n \n \n
  • \n ))}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/scroll-header/footer.tsx" }, { "path": "components/mode-toggle.tsx", "content": "\"use client\";\n\nimport { useState, useEffect, useRef, useId } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport { motion } from \"motion/react\";\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.006);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n const sine = Math.sin(2 * Math.PI * 3400 * t);\n const noise = Math.random() * 2 - 1;\n ch[i] = (sine * 0.6 + noise * 0.4) * (1 - t) ** 3;\n }\n _buf = buf;\n return buf;\n}\n\nfunction tick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n gain.gain.value = 0.08;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Component ── */\n\nexport function ModeToggle() {\n const { resolvedTheme, setTheme } = useTheme();\n const rawId = useId();\n const maskId = `mt${rawId.replace(/:/g, \"\")}`;\n const lastSnd = useRef(0);\n const isFirst = useRef(true);\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n requestAnimationFrame(() => {\n isFirst.current = false;\n });\n }, []);\n\n const isDark = resolvedTheme === \"dark\";\n\n const toggle = () => {\n setTheme(isDark ? \"light\" : \"dark\");\n tick(lastSnd);\n };\n\n /* Placeholder during SSR to avoid layout shift */\n if (!mounted) {\n return
;\n }\n\n const spring = isFirst.current\n ? { duration: 0 }\n : { type: \"spring\" as const, stiffness: 380, damping: 30 };\n\n return (\n \n \n \n \n \n \n\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n\nModeToggle.displayName = \"ModeToggle\";\n", "type": "registry:component", "target": "components/mode-toggle.tsx" }, { "path": "hooks/layouts/use-tabs.ts", "content": "import { useState } from \"react\";\n\nexport interface Tab {\n label: string;\n value: string;\n subRoutes?: string[];\n href?: string;\n}\n\nexport default function useTabs({\n tabs,\n initialTabId,\n onChange,\n}: {\n tabs: Tab[];\n initialTabId: string;\n onChange?: (id: string) => void;\n}) {\n const [[selectedTabIndex, direction], setSelectedTab] = useState(() => {\n const indexOfInitialTab = tabs.findIndex(\n (tab) => tab.value === initialTabId,\n );\n return [indexOfInitialTab === -1 ? 0 : indexOfInitialTab, 0];\n });\n\n return {\n tabProps: {\n tabs,\n selectedTabIndex,\n onChange,\n setSelectedTab,\n },\n selectedTab: tabs[selectedTabIndex],\n contentProps: {\n direction,\n selectedTabIndex,\n },\n };\n}\n", "type": "registry:hook", "target": "hooks/layouts/use-tabs.ts" }, { "path": "app/preview/layouts/scroll-header/layout.tsx", "content": "import type { Metadata } from \"next\";\nimport { ScrollHeader } from \"@/components/layouts/scroll-header/scroll-header\";\n\nexport const metadata: Metadata = {\n title: \"Animated Header — Ruixen Layouts\",\n description:\n \"A scroll-aware header with fluid animated tab transitions. Inspired by modern product-site navigation patterns.\",\n};\n\nexport default function ScrollHeaderLayout({\n children,\n}: {\n children: React.ReactNode;\n}) {\n return (\n <>\n \n {children}\n \n );\n}\n", "type": "registry:page", "target": "app/scroll-header/layout.tsx" }, { "path": "app/preview/layouts/scroll-header/page.tsx", "content": "import { DemoSkeleton } from \"@/components/layouts/scroll-header/demo-skeleton\";\n\nexport default function ScrollHeaderHomePage() {\n return ;\n}\n", "type": "registry:page", "target": "app/scroll-header/page.tsx" }, { "path": "app/preview/layouts/scroll-header/about/page.tsx", "content": "import { DemoSkeleton } from \"@/components/layouts/scroll-header/demo-skeleton\";\n\nexport default function ScrollHeaderAboutPage() {\n return ;\n}\n", "type": "registry:page", "target": "app/scroll-header/about/page.tsx" }, { "path": "app/preview/layouts/scroll-header/contact/page.tsx", "content": "import { DemoSkeleton } from \"@/components/layouts/scroll-header/demo-skeleton\";\n\nexport default function ScrollHeaderContactPage() {\n return ;\n}\n", "type": "registry:page", "target": "app/scroll-header/contact/page.tsx" }, { "path": "app/preview/layouts/scroll-header/danger-zone/page.tsx", "content": "import { DemoSkeleton } from \"@/components/layouts/scroll-header/demo-skeleton\";\n\nexport default function ScrollHeaderDangerZonePage() {\n return ;\n}\n", "type": "registry:page", "target": "app/scroll-header/danger-zone/page.tsx" } ] }