Parallax layers
A scene built from layers that move at different speeds while the page scrolls, so it reads as depth. Small shifts, half as large on phones, and without JavaScript every layer rests in the composed picture.
One of 3 components in Scroll effects
Variants
Details
What Parallax layers brings along, read from the registry and the source.
- Category
- Scroll effects
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/parallax-layers.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ParallaxLayersProps interface from components/ui/parallax-layers.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalParallaxLayersVariantlandscapeproductcollagetype
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringactionoptional{ label: string; href: string }mediaoptionalReact.ReactNodeproduct: the screen in the middle; type: the picture in front
imagesoptionalReact.ReactNode[]collage: six pictures
cardsoptionalFloatCard[]product: up to four cards floating in front of the screen
wordoptionalstringtype: the giant word far behind
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 252 lines
'use client';
import { useCallback, useEffect, useId, useRef } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A scene built from layers that move at different speeds while the page scrolls, so it reads as
* depth. Every layer gets its own shift, written as a transform straight onto it; nothing in the
* scene is repainted while scrolling. Speeds are small (24 to 220 px over the whole pass) and half
* as large on phones.
*
* Without JavaScript and with reduced motion every layer rests in its middle position, which is
* the composed picture.
*
* landscape a drawn landscape in five layers in the accent, the heading sits between them
* product a product screen with cards floating in front of it at different depths
* collage pictures drifting at their own pace around a centered message
* type a giant word far behind, a picture in front, the text in between
*/
export type ParallaxLayersVariant = 'landscape' | 'product' | 'collage' | 'type';
export interface FloatCard {
label: string;
value: string;
icon?: IconName;
}
export interface ParallaxLayersProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: ParallaxLayersVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
action?: { label: string; href: string };
/** product: the screen in the middle; type: the picture in front */
media?: React.ReactNode;
/** collage: six pictures */
images?: React.ReactNode[];
/** product: up to four cards floating in front of the screen */
cards?: FloatCard[];
/** type: the giant word far behind */
word?: string;
}
type Els = Record<string, HTMLElement | null>;
/** Layer key and its shift in px over the whole pass (positive moves up while scrolling down) */
const SPEEDS: Record<ParallaxLayersVariant, [string, number][]> = {
landscape: [['sky', 20], ['sun', 60], ['far', 90], ['head', 150], ['mid', 130], ['near', 190], ['front', 240]],
product: [['head', 40], ['screen', 70], ['c0', 100], ['c1', 95], ['c2', 125], ['c3', 115]],
collage: [['i0', 140], ['i1', 60], ['i2', 200], ['i3', 90], ['i4', 170], ['i5', 40], ['head', 20]],
type: [['word', -60], ['media', 160], ['text', 60]],
};
/* ------------------------------------------------------------------ */
/* landscape: five drawn layers in the accent */
/* ------------------------------------------------------------------ */
const tone = (k: number) => `color-mix(in oklab, var(--primary) ${k}%, var(--background))`;
function Layer({ set, k, d, fill, className }: { set: (k: string) => (n: HTMLElement | null) => void; k: string; d: string; fill: string; className?: string }) {
return (
<div ref={set(k)} aria-hidden data-clip-ok className={cn('absolute inset-x-0 -bottom-[14%] top-0 will-change-transform', className)}>
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMax slice" className="absolute inset-0 size-full">
<path d={d} fill={fill} />
</svg>
</div>
);
}
function Landscape({ set, eyebrow, title, description, action }: { set: (k: string) => (n: HTMLElement | null) => void } & Pick<ParallaxLayersProps, 'eyebrow' | 'title' | 'description' | 'action'>) {
const id = useId().replace(/:/g, '');
return (
<div className="relative h-[min(100svh,58rem)] min-h-[34rem] overflow-hidden">
<div ref={set('sky')} aria-hidden data-clip-ok className="absolute inset-x-0 -bottom-[14%] top-0 will-change-transform">
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMax slice" className="absolute inset-0 size-full">
<defs>
<linearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={tone(4)} />
<stop offset="1" stopColor={tone(22)} />
</linearGradient>
</defs>
<rect width="1600" height="900" fill={`url(#${id}s)`} />
</svg>
</div>
<div ref={set('sun')} aria-hidden className="absolute inset-0 will-change-transform">
<span className="absolute left-[74%] top-[5%] size-[clamp(4rem,12vw,11rem)] rounded-full lg:left-[80%] lg:top-[14%]" style={{ background: tone(55), boxShadow: `0 0 120px 40px ${tone(28)}` }} />
</div>
<Layer set={set} k="far" fill={tone(30)} d="M0 520L120 470L260 505L420 420L560 470L700 400L860 455L1000 380L1160 450L1300 410L1460 470L1600 430V900H0Z" />
<div ref={set('head')} className="absolute inset-x-0 top-[13%] z-10 will-change-transform">
<div className="kit-container text-center">
<div className="mx-auto max-w-[44rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-display font-semibold leading-[1.02] tracking-[-0.035em] text-foreground">{title}</h2>
{description && <p className="mx-auto mt-5 max-w-[32rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{action && (
<div className="mt-8 flex justify-center">
<div className="max-sm:w-full max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
{/* The hills from here on stand in front of the heading */}
<Layer set={set} k="mid" className="z-20" fill={tone(42)} d="M0 640C140 590 260 600 380 560S640 600 760 570S1000 520 1140 560S1380 610 1600 560V900H0Z" />
<Layer set={set} k="near" className="z-20" fill={tone(24)} d="M0 720C180 680 300 700 460 660S760 690 900 670S1200 640 1340 680S1520 700 1600 690V900H0Z" />
<Layer set={set} k="front" className="z-20" fill="var(--background)" d="M0 800C200 770 380 790 560 765S900 780 1100 760S1420 790 1600 770V900H0Z" />
</div>
);
}
/* ------------------------------------------------------------------ */
/** Cards straddle the frame edges: the top pair over the window bar, the bottom pair below the screen, so they never sit on its content. Phones keep one card below the screen. */
const CARD_POS = ['left-[-2%] bottom-[-12%] sm:bottom-auto sm:left-[1%] sm:top-[-8%] lg:left-[-4%]', 'right-[1%] top-[-6%] lg:right-[-4%]', 'left-[5%] bottom-[-11%]', 'right-[6%] bottom-[-9%]'];
function Float({ card, className }: { card: FloatCard; className?: string }) {
return (
<div className={cn('flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 shadow-[var(--highlight),var(--shadow-lg)]', className)}>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name={card.icon ?? 'sparkle'} size={17} />
</span>
<span className="min-w-0">
<span className="block text-[12px] text-muted-foreground">{card.label}</span>
<span className="block text-[17px] font-semibold tabular-nums text-foreground">{card.value}</span>
</span>
</div>
);
}
/** Around the message, never over it: the middle third stays free between the upper and lower band */
const COLLAGE_POS = [
'left-[2%] top-[4%] w-[30%] aspect-[4/5] md:w-[22%]',
'left-[33%] top-[-8%] w-[13%] aspect-square',
'right-[2%] top-[6%] w-[28%] aspect-[3/4] md:w-[21%]',
'left-[5%] bottom-[2%] w-[26%] aspect-square md:w-[17%]',
'right-[5%] bottom-[-4%] w-[34%] aspect-[4/3] md:w-[24%]',
'left-[48%] bottom-[-8%] w-[13%] aspect-[3/4]',
];
/* ------------------------------------------------------------------ */
/** A scene of layers that move at different speeds while the page scrolls. */
export function ParallaxLayers({ variant = 'landscape', eyebrow, title, description, action, media, images = [], cards = [], word, className, ...rest }: ParallaxLayersProps) {
const live = useLive();
const els = useRef<Els>({});
const set = (k: string) => (n: HTMLElement | null) => {
els.current[k] = n;
};
const apply = useCallback(
(p: number) => {
// Half the depth on phones, where the window is short and fast thumbs scroll far
const f = window.innerWidth < 640 ? 0.5 : 1;
for (const [k, speed] of SPEEDS[variant]) {
const el = els.current[k];
if (el) el.style.transform = `translate3d(0, ${round((0.5 - p) * speed * f, 1)}px, 0)`;
}
},
[variant],
);
const scene = useSceneProgress<HTMLElement>(live, { mode: 'pass', self: false, onProgress: apply });
// Back to the composed middle position when the scene stops (reduced motion switched on)
useEffect(() => {
if (live) return;
for (const [k] of SPEEDS[variant]) els.current[k]?.style.removeProperty('transform');
}, [live, variant]);
const actionEl = action && (
<div className="max-sm:w-full max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
);
return (
<section {...rest} ref={scene} className={cn('relative overflow-hidden', variant !== 'landscape' && 'py-20 sm:py-28 lg:py-36', className)}>
{variant === 'landscape' && <Landscape set={set} eyebrow={eyebrow} title={title} description={description} action={action} />}
{variant === 'product' && (
<div className="kit-container">
<div ref={set('head')} className="relative z-10 mx-auto max-w-[44rem] text-center will-change-transform">
<Reveal>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h1 font-semibold text-foreground">{title}</h2>
{description && <p className="mx-auto mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{actionEl && <div className="mt-8 flex justify-center">{actionEl}</div>}
</Reveal>
</div>
<div className="relative mx-auto mt-14 max-w-[56rem] sm:mt-20">
<div ref={set('screen')} className="will-change-transform">
{media}
</div>
{cards.slice(0, 4).map((c, i) => (
<div key={i} ref={set(`c${i}`)} className={cn('absolute z-10 will-change-transform', CARD_POS[i], i > 0 && 'hidden sm:block')}>
<Float card={c} className="scale-[0.82] sm:scale-100" />
</div>
))}
</div>
</div>
)}
{variant === 'collage' && (
<div className="relative">
<div aria-hidden data-clip-ok className="pointer-events-none absolute inset-0">
{images.slice(0, 6).map((img, i) => (
<div key={i} ref={set(`i${i}`)} className={cn('absolute will-change-transform', COLLAGE_POS[i], (i === 1 || i === 5) && 'hidden md:block')}>
<div className="size-full overflow-hidden rounded-xl shadow-[var(--shadow-lg)] [&>*]:size-full">{img}</div>
</div>
))}
</div>
<div ref={set('head')} className="kit-container relative z-10 flex min-h-[min(80svh,44rem)] items-center justify-center will-change-transform">
<div className="max-w-[22rem] py-24 text-center sm:max-w-[30rem] md:py-0">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className="mx-auto mt-4 max-w-[28rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{description}</p>}
{actionEl && <div className="mt-7 flex justify-center">{actionEl}</div>}
</div>
</div>
</div>
)}
{variant === 'type' && (
<div className="relative">
<p ref={set('word') as never} aria-hidden className="pointer-events-none absolute inset-x-0 -top-[4%] select-none whitespace-nowrap text-center text-[clamp(6rem,22vw,22rem)] font-semibold leading-none tracking-[-0.06em] text-[color-mix(in_oklab,var(--foreground)_10%,transparent)] will-change-transform">
{word}
</p>
<div className="kit-container relative grid grid-cols-1 items-center gap-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:gap-16">
<div ref={set('media')} className="will-change-transform">
<div className="aspect-[4/5] overflow-hidden rounded-2xl shadow-[var(--shadow-lg)] sm:aspect-[5/6] [&>*]:size-full">{media}</div>
</div>
<div ref={set('text')} className="will-change-transform">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className="mt-5 max-w-[30rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{actionEl && <div className="mt-8">{actionEl}</div>}
</div>
</div>
</div>
)}
</section>
);
}Frequent questions
How do I install Parallax layers?
With aiw add @ai-website/parallax-layers. The command puts components/ui/parallax-layers.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section and scroll-scene. It needs no further npm packages.
Which variants does Parallax layers have?
4 variants, Landscape in the accent, Floating cards, Drifting collage and Word, picture, text. You pick one with the variant prop, for example variant="product".
Can an AI assistant build with Parallax layers?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Parallax layers with aiw_components and install it with aiw_add @ai-website/parallax-layers. Name the variant in your prompt, for example variant="landscape", and the assistant writes your content straight into the props.
Can I adjust Parallax layers without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ParallaxLayers code to copy.