## 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={}
>
{invoices.map((invoice) => (
))}
);
}
```
### 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 (
{leading ? (
{leading}
) : null}
{title}
{subtitle ? (
{subtitle}
) : null}
{title}
{actions ? (
{actions}
) : null}
);
}
```