Depth layers
A picture that comes apart into its layers on hover, focus, a button or the scroll. Every layer keeps its name in real text beside it, without JavaScript the fan still opens on hover.
Every layer with its name
Depth layers take a picture apart into the layers it is made of. Each layer keeps its name as real text beside it, so the idea comes across before anything moves, and without JavaScript the fan still opens on hover.
Interface fanned out fans a product card sideways into picture, panel, content and action, the legend lifts one layer and a button opens the fan on touch screens. Layers on scroll turns the same card apart as the section reaches the middle of the window and keeps it apart. Diorama window shows a landscape in four depth planes that shift against each other with the mouse, with the card on a solid surface in front. Element inspector turns one element to the side and splits it into shadow, surface, border, content and focus ring.
Use the interface and scroll versions to show what a product is made of, the diorama for a mood and the inspector for a design system or a guide. Keep layer names to two or three words. One fan per section keeps the effect special.
Variants
Details
What Depth layers brings along, read from the registry and the source.
- Category
- 3D surfaces
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/depth-layers.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DepthLayersProps interface from components/ui/depth-layers.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalDepthLayersVariantinterfacescrolldioramainspector
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringlayersoptionalDepthLayer[]Names of the layers from the back to the front. interface and scroll use four, inspector five.
mediaoptionalReact.ReactNodeinterface, scroll: the picture on the back layer; diorama: replaces the drawn landscape planes (back to front)
cardoptional{ title: string; text?: string; action?: string; meta?: string }Text on the card in the picture (interface, scroll, inspector) or on the diorama
actionoptional{ label: string; href: string }
Built-in texts
Depth layers writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| open | English Show the layers | German Ebenen zeigen |
| close | English Put the layers together | German Ebenen zusammenlegen |
| layers | English Layers | German Ebenen |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-pressed, so screen readers announce purpose and state.
- Shows a visible focus ring when used with the keyboard.
- 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).
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 358 lines
'use client';
import { useCallback, useId, useRef, useState } from 'react';
import { cn, round, useTexts } 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';
import { Glare, ease3d, usePointerTilt } from '@/components/ui/depth';
/*
* A picture that comes apart into its layers. The layers sit flat on top of each other as one
* finished picture and fan out into the room on hover, on focus, with a button, or while the
* section scrolls by. Every layer keeps its name beside it in real text.
*
* interface a product card that fans out sideways into picture, panel, content and action
* scroll the same fan, driven by the scroll: apart while the section passes the middle
* diorama a drawn landscape in depth planes inside a window, leaning toward the pointer
* inspector one element turned to the side, its shadow, surface, border, content and focus ring
*
* Without JavaScript the interface fan still opens on hover (CSS only). With reduced motion the
* layers rest in their fanned position for interface, scroll and inspector, a still picture with
* every name in view, and the diorama stands still.
*/
export type DepthLayersVariant = 'interface' | 'scroll' | 'diorama' | 'inspector';
export interface DepthLayer {
title: string;
text?: string;
icon?: IconName;
}
export interface DepthLayersProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: DepthLayersVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
/** Names of the layers from the back to the front. interface and scroll use four, inspector five. */
layers?: DepthLayer[];
/** interface, scroll: the picture on the back layer; diorama: replaces the drawn landscape planes (back to front) */
media?: React.ReactNode;
/** Text on the card in the picture (interface, scroll, inspector) or on the diorama */
card?: { title: string; text?: string; action?: string; meta?: string };
action?: { label: string; href: string };
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { open: 'Show the layers', close: 'Put the layers together', layers: 'Layers' },
de: { open: 'Ebenen zeigen', close: 'Ebenen zusammenlegen', layers: 'Ebenen' },
};
const FOCUS = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary';
function Head({ eyebrow, title, description }: Pick<DepthLayersProps, 'eyebrow' | 'title' | 'description'>) {
if (!title) return null;
return (
<Reveal>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className="mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
</Reveal>
);
}
function Legend({ layers, focus, setFocus, label, reverse = true }: { layers: DepthLayer[]; focus: number; setFocus: (i: number) => void; label: string; reverse?: boolean }) {
// Listed from the front to the back, the way the eye meets them
const order = layers.map((l, i) => ({ l, i }));
if (reverse) order.reverse();
return (
<ol aria-label={label} className="mt-8 grid gap-1" onPointerLeave={() => setFocus(-1)}>
{order.map(({ l, i }) => (
<li key={i}>
<button
type="button"
aria-pressed={focus === i}
onPointerEnter={() => setFocus(i)}
onFocus={() => setFocus(i)}
onBlur={() => setFocus(-1)}
onClick={() => setFocus(focus === i ? -1 : i)}
className={cn('flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors duration-300', FOCUS, focus === i ? 'bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'hover:bg-accent')}
>
<span className={cn('flex size-7 shrink-0 items-center justify-center rounded-md transition-colors', focus === i ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
<Icon name={l.icon ?? 'layers'} size={14} />
</span>
<span className="min-w-0">
<span className="block text-[14.5px] font-semibold text-foreground">{l.title}</span>
{l.text && <span className="block text-[13.5px] text-muted-foreground">{l.text}</span>}
</span>
</button>
</li>
))}
</ol>
);
}
/* ------------------------------------------------------------------ */
/* The product card in four layers (interface, scroll) */
/* ------------------------------------------------------------------ */
function CardLayers({ media, card, focus, names, scrub }: { media?: React.ReactNode; card?: DepthLayersProps['card']; focus: number; names: string[]; scrub?: boolean }) {
// Each layer reads --open (0 flat, 1 apart) from the stage
const layer = (i: number, content: React.ReactNode, className?: string) => (
<div
className={cn('absolute inset-0 [transform-style:preserve-3d] motion-reduce:transition-none', scrub ? 'transition-opacity duration-500' : 'transition-[transform,opacity] duration-700', className)}
style={{
transform: `translateZ(calc(var(--open, 0) * ${i * 72}px + ${focus === i ? 30 : 0}px))`,
opacity: focus >= 0 && focus !== i ? 0.35 : 1,
transitionTimingFunction: ease3d,
}}
>
{content}
{/* The name of the layer beside it, only while apart */}
<span
aria-hidden
className="absolute left-[calc(100%+14px)] flex items-center gap-2 whitespace-nowrap text-[12.5px] font-medium text-foreground max-sm:hidden"
style={{ opacity: 'var(--open, 0)', top: ['6%', '44%', '58%', '84%'][i] ?? '6%' }}
>
<span className="h-px w-6 bg-border-strong" />
{names[i]}
</span>
</div>
);
return (
<>
{layer(0, <div className="absolute inset-0 overflow-hidden rounded-2xl bg-surface shadow-[var(--shadow-lg)] [&>*]:size-full">{media}</div>)}
{layer(1, <div className="absolute inset-x-[7%] bottom-[7%] top-[46%] rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_94%,transparent)] shadow-[var(--highlight),var(--shadow-md)]" />)}
{layer(
2,
<div className="absolute inset-x-[7%] bottom-[7%] top-[46%] p-[6%]">
{card?.meta && <p className="text-[11.5px] font-medium text-primary sm:text-[12.5px]">{card.meta}</p>}
<p className="mt-1 text-[15px] font-semibold leading-tight text-foreground sm:text-[18px]">{card?.title}</p>
{card?.text && <p className="mt-1.5 line-clamp-2 text-[12px] leading-snug text-muted-foreground sm:text-[13.5px]">{card.text}</p>}
</div>,
)}
{layer(
3,
<div className="absolute bottom-[12%] right-[11%]">
<span className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-[12px] font-medium text-primary-foreground shadow-[var(--shadow-md)] sm:h-9 sm:px-3.5 sm:text-[13px]">
{card?.action}
<Icon name="arrow-right" size={13} />
</span>
</div>,
)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Diorama planes */
/* ------------------------------------------------------------------ */
const tone = (k: number) => `color-mix(in oklab, var(--primary) ${k}%, var(--background))`;
const PLANES = [
{ z: -260, d: '', fill: '' },
{ z: -180, d: 'M0 520L140 440L300 490L460 380L620 450L780 360L940 430L1100 350L1260 420L1400 380L1600 440V900H0Z', fill: tone(30) },
{ z: -100, d: 'M0 620C160 560 300 590 460 540S760 580 920 545S1220 500 1380 550S1520 580 1600 560V900H0Z', fill: tone(46) },
{ z: -30, d: 'M0 730C200 690 380 715 560 680S900 705 1100 675S1420 710 1600 690V900H0Z', fill: tone(64) },
];
function Diorama({ media, card, action }: Pick<DepthLayersProps, 'media' | 'card' | 'action'>) {
const tilt = usePointerTilt<HTMLDivElement>({ ease: 0.09 });
const gid = useId().replace(/:/g, '');
const custom = Array.isArray(media) ? (media as React.ReactNode[]) : null;
return (
<div ref={tilt.ref} {...tilt.handlers} className="relative isolate aspect-[4/5] overflow-hidden rounded-3xl border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)] [perspective:900px] sm:aspect-[16/10]">
<div className="absolute inset-0 [transform-style:preserve-3d]" style={{ transform: 'rotateX(calc(var(--tilt-y, 0) * -5deg)) rotateY(calc(var(--tilt-x, 0) * 7deg))' }}>
{PLANES.map((pl, i) => (
<div key={i} aria-hidden data-clip-ok className="absolute inset-[-12%]" style={{ transform: `translateZ(${pl.z}px) scale(${round(1 + -pl.z / 900, 3)})` }}>
{custom ? (
<div className="size-full [&>*]:size-full">{custom[i % custom.length]}</div>
) : i === 0 ? (
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMax slice" className="size-full">
<defs>
<linearGradient id={`${gid}s`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={tone(6)} />
<stop offset="1" stopColor={tone(24)} />
</linearGradient>
</defs>
<rect width="1600" height="900" fill={`url(#${gid}s)`} />
<circle cx="1010" cy="300" r="90" fill={tone(55)} />
</svg>
) : (
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMax slice" className="size-full">
<path d={pl.d} fill={pl.fill} />
</svg>
)}
</div>
))}
{/* the card stands in front of every plane, on its own solid surface */}
{card && (
<div className="absolute inset-x-[6%] bottom-[7%] sm:inset-x-auto sm:left-[6%] sm:max-w-[24rem]" style={{ transform: 'translateZ(60px) scale(0.94)' }}>
<div className="relative rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
{card.meta && <p className="text-[12.5px] font-medium text-primary">{card.meta}</p>}
<p className="mt-1 text-[18px] font-semibold leading-tight text-foreground sm:text-[20px]">{card.title}</p>
{card.text && <p className="mt-2 text-[14px] leading-relaxed text-muted-foreground">{card.text}</p>}
{action && (
<div className="mt-4">
<Button href={action.href} size="sm" icon="arrow-right">
{action.label}
</Button>
</div>
)}
<Glare strength={0.22} />
</div>
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Inspector: one element turned to the side */
/* ------------------------------------------------------------------ */
function Inspector({ card, focus, open, names }: { card?: DepthLayersProps['card']; focus: number; open: boolean; names: string[] }) {
const layer = (i: number, content: React.ReactNode) => (
<div
className="absolute inset-0 [transform-style:preserve-3d] transition-[transform,opacity] duration-700 motion-reduce:transition-none"
// The stack stands around its middle layer, so the turn swings it evenly to both sides and it never reaches over the copy
style={{ transform: `translateZ(calc(${open ? i - 2 : round((i - 2) * 0.03, 2)} * var(--insp-gap) + ${focus === i ? 24 : 0}px))`, opacity: focus >= 0 && focus !== i ? 0.3 : 1, transitionTimingFunction: ease3d }}
>
{content}
<span aria-hidden className={cn('absolute -top-7 left-0 whitespace-nowrap text-[12px] font-medium text-muted-foreground transition-opacity duration-500 max-sm:hidden', open ? 'opacity-100' : 'opacity-0')}>
{names[i]}
</span>
</div>
);
return (
<div className="relative mx-auto aspect-[1.5] w-[78%] [--insp-gap:46px] [transform-style:preserve-3d] sm:[--insp-gap:62px] lg:[--insp-gap:74px]">
{layer(0, <div className="absolute inset-[-6%] rounded-[1.6rem] bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)] blur-xl dark:bg-[color-mix(in_oklab,black_60%,transparent)]" />)}
{layer(1, <div className="absolute inset-0 rounded-2xl bg-card" />)}
{layer(2, <div className="absolute inset-0 rounded-2xl border-2 border-border-strong" />)}
{layer(
3,
<div className="absolute inset-0 flex flex-col justify-between p-[8%]">
<div>
{card?.meta && <p className="text-[12px] font-medium text-primary">{card.meta}</p>}
<p className="mt-1 text-[17px] font-semibold text-foreground sm:text-[19px]">{card?.title}</p>
{card?.text && <p className="mt-1 text-[13px] text-muted-foreground">{card.text}</p>}
</div>
<span className="inline-flex h-9 w-fit items-center rounded-lg bg-primary px-4 text-[13px] font-medium text-primary-foreground">{card?.action}</span>
</div>,
)}
{layer(4, <div className="absolute -inset-2 rounded-[1.15rem] border-2 border-primary" />)}
</div>
);
}
/* ------------------------------------------------------------------ */
const DEFAULT_LAYERS: Record<'en' | 'de', DepthLayer[]> = {
en: [{ title: 'Picture' }, { title: 'Panel' }, { title: 'Content' }, { title: 'Action' }, { title: 'Focus ring' }],
de: [{ title: 'Bild' }, { title: 'Fläche' }, { title: 'Inhalt' }, { title: 'Aktion' }, { title: 'Fokusring' }],
};
/** A picture that comes apart into its layers. */
export function DepthLayers({ variant = 'interface', eyebrow, title, description, layers, media, card, action, labels, className, ...rest }: DepthLayersProps) {
const t = useTexts(TEXTS, labels);
const de = t.open === TEXTS.de.open;
const list = layers?.length ? layers : DEFAULT_LAYERS[de ? 'de' : 'en'];
const names = list.map((l) => l.title);
const [focus, setFocus] = useState(-1);
const [open, setOpen] = useState(variant === 'inspector');
const stageRef = useRef<HTMLDivElement | null>(null);
// scroll: --open follows the progress, peaking while the section passes the middle of the window
const live = useLive();
const apply = useCallback((p: number) => {
const el = stageRef.current;
if (!el) return;
const x = Math.min(1, Math.max(0, (p - 0.18) / 0.3));
const v = x * x * (3 - 2 * x);
el.style.setProperty('--open', String(round(v, 3)));
el.style.setProperty('--turn', String(round(v, 3)));
}, []);
const scene = useSceneProgress<HTMLElement>(variant === 'scroll' && live, { mode: 'pass', self: false, onProgress: apply });
const actionEl = action && (
<div className="mt-8 max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" variant="secondary" className="max-sm:w-full">
{action.label}
</Button>
</div>
);
if (variant === 'diorama') {
return (
<section {...rest} className={cn('relative py-20 sm:py-28', className)}>
<div className="kit-container">
{title && (
<div className="mb-10 grid gap-6 sm:mb-14 md:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)] md:items-end">
<Reveal>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
</Reveal>
{description && <p className="text-pretty text-lead text-muted-foreground">{description}</p>}
</div>
)}
<Diorama media={media} card={card} action={action} />
</div>
</section>
);
}
const isInspector = variant === 'inspector';
const turn = isInspector ? 'rotateX(8deg) rotateY(-42deg)' : 'rotateX(calc(var(--turn, 0) * 12deg)) rotateY(calc(var(--turn, 0) * -38deg))';
return (
<section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div className="kit-container grid grid-cols-1 items-center gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<div className="max-lg:contents">
<div className="max-lg:order-1">
<Head eyebrow={eyebrow} title={title} description={description} />
</div>
<div className="max-lg:order-4 [&>ol]:max-lg:mt-0">
<Legend layers={list.slice(0, isInspector ? 5 : 4)} focus={focus} setFocus={setFocus} label={t.layers} />
</div>
{variant !== 'scroll' && (
<button type="button" aria-pressed={open} onClick={() => setOpen((v) => !v)} className={cn('inline-flex min-h-11 w-fit cursor-pointer items-center gap-2 rounded-lg px-3 text-[14px] font-medium text-primary transition-colors hover:bg-accent max-lg:order-3 max-lg:justify-self-center lg:mt-4', FOCUS)}>
<Icon name={open ? 'minimize' : 'layers'} size={16} />
{open ? t.close : t.open}
</button>
)}
<div className="max-lg:order-5">{actionEl}</div>
</div>
<div
ref={stageRef}
aria-hidden
className={cn(
'group/layers relative py-10 [perspective:1600px] max-lg:order-2 sm:py-14 lg:pr-28',
// interface: CSS hover opens the fan even without JavaScript; the button and the legend open it as well
variant === 'interface' && 'hover:[--open:1] hover:[--turn:1]',
variant === 'interface' && (open || focus >= 0) && '[--open:1] [--turn:1]',
// scroll without a running scene (no JavaScript, reduced motion) rests apart
variant === 'scroll' && !live && '[--open:1] [--turn:1]',
)}
>
{isInspector ? (
<div className="[transform-style:preserve-3d]" style={{ transform: turn }}>
<Inspector card={card} focus={focus} open={open} names={names} />
</div>
) : (
<div
className={cn('relative mx-auto aspect-[4/5] w-[min(100%,22rem)] [transform-style:preserve-3d] motion-reduce:transition-none sm:w-[24rem]', !(variant === 'scroll' && live) && 'transition-transform duration-700')}
style={{ transform: turn, transitionTimingFunction: ease3d }}
>
<CardLayers media={media} card={card} focus={focus} names={names} scrub={variant === 'scroll' && live} />
</div>
)}
</div>
</div>
</section>
);
}Frequent questions
How do I install Depth layers?
With aiw add @ai-website/depth-layers. The command puts components/ui/depth-layers.tsx into your project, the code is yours from then on. It brings along section, button, icon, reveal, scroll-scene and depth. It needs no further npm packages.
Which variants does Depth layers have?
4 variants, Interface fanned out, Layers on scroll, Diorama window and Element inspector. You pick one with the variant prop, for example variant="scroll".
Can I translate the texts of Depth layers?
Yes. Depth layers carries 3 texts of its own in English and German, such as “Show the layers”, “Put the layers together” and “Layers”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Depth layers?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Depth layers with aiw_components and install it with aiw_add @ai-website/depth-layers. Name the variant in your prompt, for example variant="interface", and the assistant writes your content straight into the props.