Skip to content
UI Kit
Accent color

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.

4 variantsOne of 6 components in 3D surfaces

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

Interface fanned outNew in 1.2

A product card fans out sideways into picture, panel, content and action on hover. The legend lifts one layer, a button opens the fan on touch screens.

Layers on scrollNew in 1.2

The same card turns and comes apart while the section scrolls into the middle of the window, and stays apart. Without JavaScript it rests apart.

Diorama windowNew in 1.2

A landscape in four depth planes behind a window, tinted in the accent. The scene leans with the mouse and the planes shift against each other, the card stands in front on a solid surface.

Element inspectorNew in 1.2

One element turned to the side and taken apart, shadow, surface, border, content and focus ring. Good for a design system or a guide to good components.

Details

What Depth layers brings along, read from the registry and the source.

Category
3D surfaces
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/depth-layers.tsx
Brings along
utilskitsectionbuttoniconrevealscroll-scenedepth
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

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'>.

  • variantoptional
    DepthLayersVariant

    interfacescrolldioramainspector

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • layersoptional
    DepthLayer[]

    Names of the layers from the back to the front. interface and scroll use four, inspector five.

  • mediaoptional
    React.ReactNode

    interface, 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
openEnglish Show the layersGerman Ebenen zeigen
closeEnglish Put the layers togetherGerman Ebenen zusammenlegen
layersEnglish LayersGerman 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
components/ui/depth-layers.tsx
'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.

Often searched

Jump to

Copied to the clipboard