Skip to content
Corsair UI
Browse the docs

MotionNo. 108 of 145

Tilt Scroll

Content that starts tilted back in perspective and straightens as you scroll, with CSS scroll-driven animations and no JavaScript.

Installation

pnpm dlx shadcn@latest add @corsair-ui/tilt-scroll

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
@radix-ui/react-slotclsxtailwind-merge

Examples

A dashboard that straightens

Scroll it into view; flat below 768px with flatBelow="md".

Berths booked

82%

Open hours

22 h

Waiting list

7

Steeper, with a shorter range

angle={35}, scale={0.8}, perspective={800} and range="entry 10% cover 50%".

Tilted

Next stop

Havana, 20:00

API

PropTypeDefault
angle / scale / perspective

The starting lean in degrees, size and depth in px.

number20 / 0.9 / 1200
timeline

The element crossing the viewport, or the page scroll from the top (for a hero in view at load).

"view" | "root""view"
range

The animation-range the straightening spans.

string"entry 0% cover 40%" / "0px 60vh"
flatBelow

Stay flat below this breakpoint, by CSS alone.

"sm" | "md" | "lg" | falsefalse
asChild

Tilt the child itself.

booleanfalse

Styling

Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=tilt-scroll]]:text-primary".

data-slot
  • tilt-scroll
State
  • data-timelinecomputed

Accessibility

  • Pure CSS; browsers without scroll-driven animations, and prefers-reduced-motion, show it flat.

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
tilt-scroll.tsxView on GitHub
import { Slot } from "@radix-ui/react-slot";
import type { ComponentProps, CSSProperties } from "react";

import { cn } from "@/registry/default/lib/utils";

// Literal classes, so Tailwind finds them in the source.
const FLAT_BELOW = {
  sm: "max-sm:!animate-none",
  md: "max-md:!animate-none",
  lg: "max-lg:!animate-none",
} as const;

const DEFAULT_RANGE = {
  // From the first pixel entering until it has covered 40% of its crossing.
  view: "entry 0% cover 40%",
  // A hero is already on screen at load: straighten over the first 60vh of page scroll.
  root: "0px 60vh",
} as const;

interface TiltScrollProps extends ComponentProps<"div"> {
  /** Apply the tilt to the single child element instead of wrapping it in a div. */
  asChild?: boolean;
  /** How far it leans back at the start, in degrees around the horizontal axis. */
  angle?: number;
  /** How large it starts, as a fraction of its size. */
  scale?: number;
  /** Distance to the viewer for the 3D effect, in px. Smaller is more dramatic. */
  perspective?: number;
  /**
   * What drives it: "view" is the element crossing the viewport, "root" the
   * page's scroll from the top, for a hero that is in view at load.
   */
  timeline?: "view" | "root";
  /**
   * The CSS `animation-range` the straightening spans. Defaults to
   * "entry 0% cover 40%" for "view" and "0px 60vh" for "root".
   */
  range?: string;
  /** Below this breakpoint it stays flat, by CSS alone. */
  flatBelow?: keyof typeof FLAT_BELOW | false;
}

/**
 * Starts content tilted back in perspective, slightly smaller, and
 * straightens it as you scroll: the "container scroll" hero where a product
 * screenshot rises to face the reader. It runs on CSS scroll-driven
 * animations, so there is no JavaScript and no scroll listener, and it works
 * in server components. Browsers without scroll timelines, `prefers-reduced-motion`
 * and viewports below `flatBelow` show it flat, its finished state.
 *
 * It tilts from the top edge (`origin-top`); pass another `origin-*` class to
 * change that. It follows the nearest scroll container with
 * `timeline="view"`, and `overflow: hidden` makes one that never scrolls:
 * clip a wrapper with `overflow-clip` instead.
 *
 * @example
 * <TiltScroll timeline="root" flatBelow="md" className="mx-auto max-w-5xl">
 *   <BrowserFrame url="app.example.com">
 *     <img src="/dashboard.png" alt="The dashboard" />
 *   </BrowserFrame>
 * </TiltScroll>
 */
function TiltScroll({
  asChild = false,
  angle = 20,
  scale = 0.9,
  perspective = 1200,
  timeline = "view",
  range,
  flatBelow = false,
  className,
  style,
  ...props
}: TiltScrollProps) {
  const Comp = asChild ? Slot : "div";
  return (
    <Comp
      data-slot="tilt-scroll"
      data-timeline={timeline}
      className={cn(
        "supports-[animation-timeline:view()]:animate-tilt-scroll origin-top motion-reduce:!animate-none",
        flatBelow ? FLAT_BELOW[flatBelow] : undefined,
        className
      )}
      style={
        {
          "--tilt-scroll-angle": `${angle}deg`,
          "--tilt-scroll-scale": scale,
          "--tilt-scroll-perspective": `${perspective}px`,
          // After the utility's shorthand, which would reset them.
          animationTimeline: timeline === "root" ? "scroll(root)" : "view()",
          animationRange: range ?? DEFAULT_RANGE[timeline],
          ...style,
        } as CSSProperties
      }
      {...props}
    />
  );
}

export { TiltScroll, type TiltScrollProps };