## Sticky Header — Scroll Condenses as you go down. Docs: https://www.interior.dev/docs/sticky-header Reference: https://www.interior.dev/reference/sticky-header License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/sticky-header.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx import { StickyHeader } from "@/components/interior/sticky-header"; import { InvoiceRow } from "@/components/invoice-row"; export function InvoicePanel({ invoices }: { invoices: Invoice[] }) { const open = invoices.filter((i) => !i.paid).length; return ( } actions={} > ); } ``` ### Props - `title` (`string`) The large title, and the label the compact bar crossfades to. Reaches the accessibility tree exactly once. - `children` (`React.ReactNode`) The scrollable content. Passed as a prop, so a condense step never re-renders it. - `subtitle` (`string`) — default: `undefined` Secondary line under the title. Its text can change length freely; it is out of flow, so nothing reflows. - `leading` (`React.ReactNode`) — default: `undefined` Left slot in the compact bar, present in every state. Interactive, unlike the rest of the header. - `actions` (`React.ReactNode`) — default: `undefined` Right slot in the compact bar. Stays at a fixed height so condensing never moves it. - `expandedHeight` (`number`) — default: `68` Height reserved at rest, in pixels. The spacer under the header matches it from the first paint. There is no reserved bar zone above the title — the large title, leading and actions share one band, so this only needs room for the title and its subtitle. - `compactHeight` (`number`) — default: `48` Height of the condensed bar. Also the scroll-padding-top of the region. - `maxHeight` (`number`) — default: `320` Cap on the scroll region. The panel never animates toward an unbounded height. - `className` (`string`) — default: `""` Appended last to the outer panel so callers can override the surface, border and radius. ### Behavior notes - The expanded height is reserved by a spacer from the first paint, so condensing moves nothing below it; a header that shrinks inside the flow instead pulls its own scroll position and oscillates around the threshold. - The bar's edge is driven by a scaleY on a plain plate and a translate on the hairline, so no height, top or width property is animated while you scroll — and every value is a MotionValue read off the scroll position, so React renders once, at the threshold. - The two titles never share a frame: the large one is gone by 0.45, the compact one arrives after 0.55, so the handoff has a beat of silence in it instead of two crossfading ghosts mid-scroll. - Content is never guillotined at the bar. As rows pass underneath, a hairline, a dissolving gradient and a cast shadow arrive together on the same edge — the shadow baked onto a strip whose opacity is composited, never a box-shadow repainted per scroll frame — and the foot of the box fades the same way. - One copy of the title reaches the accessibility tree; the compact duplicate is aria-hidden, so the region is announced once rather than twice per scroll. The region itself is focusable, with scroll-padding equal to the condensed height so tabbing to a row never parks it under the bar. - The condense is decoupled from the geometry — the header moves ~20px but the transition is paid out over ~64px of scroll, through a spring, so a hard flick lands as a settle instead of a cut. Under prefers-reduced-motion the spring is bypassed and every value resolves on the frame it is scrolled to. ### Source (`components/interior/sticky-header.tsx`) ```tsx "use client"; import { useRef, useState } from "react"; import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue, } from "motion/react"; const SMOOTH = { stiffness: 240, damping: 44, mass: 0.6 } as const; export type UseCondenseOptions = { range?: number; }; export type UseCondenseResult = { ref: React.RefObject; progress: MotionValue; condensed: boolean; }; export function useCondense({ range = 48, }: UseCondenseOptions = {}): UseCondenseResult { const ref = useRef(null); const { scrollY } = useScroll({ container: ref }); const progress = useTransform(scrollY, [0, Math.max(1, range)], [0, 1], { clamp: true, }); const [condensed, setCondensed] = useState(false); useMotionValueEvent(progress, "change", (p) => { const done = p >= 1; setCondensed((prev) => (prev === done ? prev : done)); }); return { ref, progress, condensed }; } export type StickyHeaderProps = { title: string; children: React.ReactNode; subtitle?: string; leading?: React.ReactNode; actions?: React.ReactNode; expandedHeight?: number; compactHeight?: number; maxHeight?: number; className?: string; }; export function StickyHeader({ title, children, subtitle, leading, actions, expandedHeight = 68, compactHeight = 48, maxHeight = 320, className = "", }: StickyHeaderProps) { const tall = Math.max(expandedHeight, compactHeight); const short = Math.min(expandedHeight, compactHeight); const travel = Math.max(1, tall - short); const { ref, progress: tracked, condensed } = useCondense({ range: Math.max(64, travel * 3), }); const reduced = useReducedMotion(); const sprung = useSpring(tracked, SMOOTH); const progress = reduced ? tracked : sprung; const plate = useTransform(progress, (p) => (tall - travel * p) / tall); const edge = useTransform(progress, (p) => tall - travel * p); const lifted = useTransform(progress, [0, 0.12], [0, 1], { clamp: true }); const bigY = useTransform(progress, (p) => -travel * p); const bigOpacity = useTransform(progress, [0, 0.45], [1, 0], { clamp: true }); const bigScale = useTransform(progress, (p) => 1 - 0.05 * p); const smallOpacity = useTransform(progress, [0.55, 0.9], [0, 1], { clamp: true, }); const smallY = useTransform(smallOpacity, (o) => (1 - o) * 6); return (
{children}
{leading ? (
{leading}
) : null}

{title}

{subtitle ? (

{subtitle}

) : null}
{title}
{actions ? (
{actions}
) : null}
); } ```