Browse the docs
MotionNo. 103 of 145
Scroll Background
A background that blends from one colour to the next as you scroll, with CSS scroll-driven animations and no JavaScript.
Installation
pnpm dlx shadcn@latest add @corsair-ui/scroll-backgroundNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utils
- npm
clsxtailwind-merge
Examples
Inside a scroll box
timeline="nearest": four colours across the box's scroll.
Morning watch
04:00 to 08:00
Forenoon watch
08:00 to 12:00
Afternoon watch
12:00 to 16:00
Dog watches
16:00 to 20:00
First watch
20:00 to 00:00
Middle watch
00:00 to 04:00
As it crosses the window
The default view timeline, with range="contain".
Dusk falls as you scroll
This box shifts colour while it crosses the window.
Uneven stops
stops={[0, 10, 30, 100]}: the second colour arrives early, the last one late.
Morning watch
04:00 to 08:00
Forenoon watch
08:00 to 12:00
Afternoon watch
12:00 to 16:00
Dog watches
16:00 to 20:00
First watch
20:00 to 00:00
Middle watch
00:00 to 04:00
API
| Prop | Type | Default |
|---|---|---|
colorsAny CSS colours, in order. The first shows before scrolling and where scroll timelines are missing. | string[] | background, muted, a primary tint |
timelineThe element crossing the window, the page, or the nearest scroll box. | "view" | "root" | "nearest" | "view" |
rangeView timeline: the whole crossing, or only while it fills the window. | "cover" | "contain" | "cover" |
stopsWhere each colour is reached, in % of the timeline. Without it they are spread evenly. | number[] | — |
Styling
Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=scroll-background-layer]]:text-primary".
- The root takes the first colour as its background. Each further colour is a scroll-background-layer under the content, inside the root's own stacking context (isolate).
- Clip a wrapper with overflow-clip, not overflow-hidden: overflow-hidden makes a scroll box that never scrolls, and the colours stay put.
- data-slot
scroll-backgroundscroll-background-layerscroll-background-layers
- State
data-timelinecomputed
Accessibility
- The colour layers are decorative and hidden from screen readers.
- Every colour has to contrast with the text on it; the blend between two passing colours stays between them.
- Pure CSS, and nothing moves, so it keeps following the scroll with reduced motion.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Works in server components
- Motion
- Respects prefers-reduced-motion
Source
1 file, installed as source you own
scroll-background.tsxView on GitHubimport type { ComponentProps, CSSProperties } from "react";
import { cn } from "@/registry/default/lib/utils";
interface ScrollBackgroundProps extends ComponentProps<"div"> {
/**
* The colours it moves through, in order, as any CSS colours. The first
* is the background before any scrolling, and where scroll timelines are
* not supported. Every one of them has to contrast with the text on it.
*/
colors?: string[];
/**
* What drives it: "view" is the element crossing the viewport, "root" the
* page's scroll, "nearest" the closest scrolling ancestor.
*/
timeline?: "view" | "root" | "nearest";
/**
* With `timeline="view"`, which part of the crossing the colours spread
* over: "cover" from the first pixel entering to the last leaving,
* "contain" while the element fills the viewport (or fits inside it).
*/
range?: "cover" | "contain";
/**
* Where each colour is reached, in percent of the timeline, one per
* colour and in increasing order: `[0, 5, 14, 65, 100]`. The blend into a
* colour runs from the stop before it to its own. Without it the colours
* are spread evenly.
*/
stops?: number[];
}
/** Evenly spread stops from 0 to 100, or the given ones clamped and kept in order. */
function resolveStops(count: number, stops?: number[]) {
if (!stops || stops.length !== count) {
return Array.from({ length: count }, (_, index) =>
count > 1 ? (index / (count - 1)) * 100 : 0
);
}
let floor = 0;
return stops.map((stop) => {
floor = Math.min(Math.max(stop, floor), 100);
return floor;
});
}
const percent = (value: number) => `${Math.round(value * 100) / 100}%`;
const DEFAULT_COLORS = [
"var(--background)",
"var(--muted)",
"color-mix(in srgb, var(--primary) 12%, var(--background))",
];
/**
* A background that shifts from one colour to the next as you scroll. It is
* CSS scroll-driven animation: each colour is a layer that fades in over its
* own stretch of the scroll, so neighbouring colours blend evenly, with no
* JavaScript and no scroll listener. Works in server components. Browsers
* without scroll timelines keep the first colour. The layers are decorative
* and hidden from screen readers. Like `scroll-progress`, it keeps following
* the scroll with `prefers-reduced-motion`, since nothing moves.
*
* It follows the nearest scroll container, and `overflow: hidden` makes one
* that never scrolls. Clip with `overflow: clip` (`overflow-clip`) instead.
*
* For a background behind the whole page, give it no children and fix it in
* place: `<ScrollBackground timeline="root" className="fixed inset-0 -z-10" />`.
* A colour can come back later in the list: each layer covers the ones before.
*
* @example
* <ScrollBackground colors={["#0b1d2a", "#12344d", "#1f5f7a"]} className="text-white">
* <section className="min-h-svh">…</section>
* </ScrollBackground>
*/
function ScrollBackground({
colors = DEFAULT_COLORS,
timeline = "view",
range = "cover",
stops,
className,
style,
children,
...props
}: ScrollBackgroundProps) {
const [base, ...rest] = colors;
const reached = resolveStops(colors.length, stops);
const animationTimeline =
timeline === "view" ? "view()" : timeline === "nearest" ? "scroll(nearest)" : "scroll(root)";
// Layer i (colour i + 1) fades in between the stop before its colour and its own.
const stretch = (index: number) => {
const start = percent(reached[index]!);
const end = percent(reached[index + 1]!);
return timeline === "view" ? `${range} ${start} ${range} ${end}` : `${start} ${end}`;
};
return (
<div
data-slot="scroll-background"
data-timeline={timeline}
className={cn("relative isolate", className)}
style={{ backgroundColor: base, ...style }}
{...props}
>
<div
aria-hidden="true"
data-slot="scroll-background-layers"
className="pointer-events-none absolute inset-0 -z-10"
>
{rest.map((color, index) => (
<div
key={index}
data-slot="scroll-background-layer"
className="supports-[animation-timeline:scroll()]:animate-scroll-background absolute inset-0 opacity-0"
style={
{
backgroundColor: color,
// After the utility's shorthand, which would reset them.
animationTimeline,
animationRange: stretch(index),
} as CSSProperties
}
/>
))}
</div>
{children}
</div>
);
}
export { ScrollBackground, type ScrollBackgroundProps };