Skip to content
Corsair UI
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-background

No 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

PropTypeDefault
colors

Any CSS colours, in order. The first shows before scrolling and where scroll timelines are missing.

string[]background, muted, a primary tint
timeline

The element crossing the window, the page, or the nearest scroll box.

"view" | "root" | "nearest""view"
range

View timeline: the whole crossing, or only while it fills the window.

"cover" | "contain""cover"
stops

Where 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-background
  • scroll-background-layer
  • scroll-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 GitHub
import 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 };