Skip to content
UI Kit
Accent color

Perspective floor

A floor of lines or dots that runs to a soft horizon, as a stage for a heading, cards, figures or one offer. It streams toward you while scrolling and drifts slowly while on screen, text always stands on the calm page colour.

4 variantsOne of 6 components in 3D scenes

A stage that runs to the horizon

A perspective floor gives a section depth with a floor of lines or dots that runs to a soft horizon. It streams toward you while scrolling and drifts slowly while on screen, and text always stands on the calm page color, so headings keep their contrast.

Opening over a grid sets a large heading above a floor of accent lines that glows at the horizon, a strong first section for a product or an event. Cards on a stage stand three cards on a floor of dots with a soft reflection, the middle one a step forward. Figures as bars raise figures as solid bars out of the floor, with every value in plain text in the legend. Offer above the horizon floats one card with an offer and a button above a horizon of light.

Use the floor once per page, for the opening or the closing offer. Save the accent for the horizon and one button. The bars version suits three to five figures.

Variants

Opening over a gridNew in 1.2

A large heading above a floor of lines in the accent that glows at the horizon. Good as the first section of a product or an event.

Cards on a stageNew in 1.2

Three cards stand on a floor of dots with a soft reflection, the middle one a step forward. The camera swings a little while the page scrolls.

Figures as barsNew in 1.2

Figures rise as solid bars out of a floor while the section scrolls into view, the last one in the accent. The legend beside them carries every value in plain text.

Offer above the horizonNew in 1.2

A floor of dots toward a horizon of light in the accent, one card floats above it with an offer and a button.

Details

What Perspective floor brings along, read from the registry and the source.

Category
3D scenes
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/perspective-floor.tsx
Brings along
utilskitsectionbuttoniconrevealscroll-scenedepth
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The PerspectiveFloorProps interface from components/ui/perspective-floor.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    PerspectiveFloorVariant

    gridstagebarshorizon

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode
  • descriptionoptional
    string
  • itemsoptional
    FloorItem[]
  • patternoptional
    'lines' | 'dots'

    The floor pattern. grid and bars default to lines, stage and horizon to dots.

  • actionoptional
    { label: string; href: string }
  • secondaryoptional
    { label: string; href: string }
  • driftoptional
    boolean

    Drift on its own while on screen. Default true.

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).
  • Brings animations of its own, calmly timed and without wobble.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 295 lines
components/ui/perspective-floor.tsx
'use client';

import { useCallback, useEffect, 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 { seg, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
import { useActive } from '@/components/ui/depth';

/*
 * A floor with depth as a stage. A plane of lines or dots runs from the viewer to a soft horizon,
 * the content stands above it or on it. The floor streams toward the viewer while the page scrolls
 * and drifts slowly on its own while it is on screen; the drift pauses outside the window and in a
 * hidden tab. Only one transform per frame is written.
 *
 * The heading never stands on the floor itself: the horizon sits below it and fades into the
 * background, so text keeps the calm page colour behind it.
 *
 *   grid     an opening section, heading above a floor of lines that glows at the horizon
 *   stage    cards standing on the floor with a soft reflection, the camera swings with the scroll
 *   bars     figures as solid bars rising out of the floor, with a legend in plain text
 *   horizon  a floor of dots toward a horizon of light, a card floats above it
 *
 * Without JavaScript and with reduced motion the floor stands still and the bars stand at full height.
 */

export type PerspectiveFloorVariant = 'grid' | 'stage' | 'bars' | 'horizon';

export interface FloorItem {
  title: string;
  text?: string;
  /** bars: the value shown, e.g. "86 %" */
  meta?: string;
  /** bars: the height, 0 to 100 */
  value?: number;
  icon?: IconName;
}

export interface PerspectiveFloorProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: PerspectiveFloorVariant;
  eyebrow?: string;
  title: React.ReactNode;
  description?: string;
  items?: FloorItem[];
  /** The floor pattern. grid and bars default to lines, stage and horizon to dots. */
  pattern?: 'lines' | 'dots';
  action?: { label: string; href: string };
  secondary?: { label: string; href: string };
  /** Drift on its own while on screen. Default true. */
  drift?: boolean;
}

const CELL = 64;

/** The floor plane: a wide sheet laid back into the room, its far edge fading out */
function Floor({ pattern, setRef, tilt = 74, top = '58%', glow = true }: { pattern: 'lines' | 'dots'; setRef: (el: HTMLDivElement | null) => void; tilt?: number; top?: string; glow?: boolean }) {
  const lines = `linear-gradient(to right, color-mix(in oklab, var(--primary) 34%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--primary) 34%, transparent) 1px, transparent 1px)`;
  const dots = 'radial-gradient(color-mix(in oklab, var(--foreground) 30%, transparent) 1.4px, transparent 1.8px)';
  return (
    <div aria-hidden data-clip-ok className="pointer-events-none absolute inset-x-0 bottom-0 [perspective:520px] [perspective-origin:50%_0%]" style={{ top }}>
      {glow && <span className="absolute inset-x-[10%] -top-16 h-32 rounded-[50%] bg-[color-mix(in_oklab,var(--primary)_32%,transparent)] blur-3xl" />}
      <div className="absolute inset-x-[-120%] top-0 h-[260%] origin-top [transform-style:preserve-3d]" style={{ transform: `rotateX(${tilt}deg)` }}>
        <div
          ref={setRef}
          data-clip-ok
          className="absolute inset-x-0 top-[-128px] bottom-0 will-change-transform"
          style={{
            backgroundImage: pattern === 'lines' ? lines : dots,
            backgroundSize: pattern === 'lines' ? `${CELL}px ${CELL}px` : `${CELL / 2}px ${CELL / 2}px`,
            backgroundPosition: 'center top',
            maskImage: 'linear-gradient(to bottom, transparent 0%, black 18%, black 55%, transparent 100%)',
          }}
        />
      </div>
      {/* the horizon melts into the page */}
      <span className="absolute inset-x-0 top-0 h-[38%] bg-gradient-to-b from-background to-transparent" />
    </div>
  );
}

function Head({ eyebrow, title, description, action, secondary, big = false }: Pick<PerspectiveFloorProps, 'eyebrow' | 'title' | 'description' | 'action' | 'secondary'> & { big?: boolean }) {
  return (
    <Reveal className="relative z-10 mx-auto max-w-[46rem] text-center">
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className={cn('text-balance font-semibold text-foreground', big ? 'text-display leading-[1.02] tracking-[-0.035em]' : 'text-h1')}>{title}</h2>
      {description && <p className="mx-auto mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
      {(action || secondary) && (
        <div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
          {action && (
            <span className="max-sm:[&>span]:w-full">
              <Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
                {action.label}
              </Button>
            </span>
          )}
          {secondary && (
            <Button href={secondary.href} variant="secondary" className="max-sm:w-full">
              {secondary.label}
            </Button>
          )}
        </div>
      )}
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */

/** A floor with depth as a stage for a heading, cards, figures or a single offer. */
export function PerspectiveFloor({ variant = 'grid', eyebrow, title, description, items = [], pattern, action, secondary, drift = true, className, ...rest }: PerspectiveFloorProps) {
  const live = useLive();
  const floor = useRef<HTMLDivElement | null>(null);
  const cam = useRef<HTMLUListElement | null>(null);
  const prog = useRef(0);
  const [watch, active] = useActive<HTMLDivElement>();
  const pat = pattern ?? (variant === 'grid' || variant === 'bars' ? 'lines' : 'dots');
  const unit = pat === 'lines' ? CELL : CELL / 2;

  const write = useCallback(
    (t: number) => {
      const el = floor.current;
      if (el) el.style.transform = `translate3d(0, ${round((prog.current * unit * 8 + t * 0.012) % (unit * 2), 2)}px, 0)`;
    },
    [unit],
  );
  const onProgress = useCallback(
    (p: number) => {
      prog.current = p;
      write(0);
      if (variant === 'stage' && cam.current) cam.current.style.transform = `rotateX(8deg) rotateY(${round((p - 0.5) * 22, 2)}deg)`;
    },
    [variant, write],
  );
  const scene = useSceneProgress<HTMLElement>(live, { mode: variant === 'bars' ? 'enter' : 'pass', self: variant === 'bars', onProgress });

  // A slow drift while on screen, paused outside the window and in hidden tabs
  useEffect(() => {
    if (!live || !active || !drift || variant === 'bars') return;
    let frame = 0;
    let t0 = 0;
    const loop = (now: number) => {
      if (!t0) t0 = now;
      write(now - t0);
      frame = requestAnimationFrame(loop);
    };
    frame = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(frame);
  }, [live, active, drift, variant, write]);

  const setFloor = (el: HTMLDivElement | null) => {
    floor.current = el;
  };

  /* ---------- grid ---------- */
  if (variant === 'grid') {
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-hidden', className)}>
        <div ref={watch} className="relative flex min-h-[min(92svh,52rem)] flex-col items-center justify-start px-6 pb-[32svh] pt-[clamp(4rem,14svh,9rem)] sm:pb-[30svh]">
          <Floor pattern={pat} setRef={setFloor} top="62%" />
          <Head eyebrow={eyebrow} title={title} description={description} action={action} secondary={secondary} big />
        </div>
      </section>
    );
  }

  /* ---------- horizon ---------- */
  if (variant === 'horizon') {
    const it = items[0];
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-hidden', className)}>
        <div ref={watch} className="relative px-6 pb-16 pt-20 sm:pb-24 sm:pt-28">
          <span aria-hidden className="pointer-events-none absolute left-1/2 top-[56%] h-48 w-[min(96vw,64rem)] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_45%,transparent),transparent)] blur-2xl" />
          <Floor pattern={pat} setRef={setFloor} top="56%" tilt={78} glow={false} />
          <Head eyebrow={eyebrow} title={title} description={description} />
          {it && (
            <div className="relative z-10 mx-auto mt-12 max-w-[30rem] sm:mt-16">
              <div className={cn('rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] motion-safe:animate-kit-float sm:p-7', !active && '[animation-play-state:paused]')}>
                <div className="flex items-start gap-4">
                  <span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
                    <Icon name={it.icon ?? 'sparkle'} size={20} />
                  </span>
                  <div className="min-w-0">
                    <p className="text-[17px] font-semibold text-foreground">{it.title}</p>
                    {it.text && <p className="mt-1 text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
                  </div>
                  {it.meta && <span className="ml-auto shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{it.meta}</span>}
                </div>
                {action && (
                  <div className="mt-5">
                    <Button href={action.href} icon="arrow-right" className="w-full">
                      {action.label}
                    </Button>
                  </div>
                )}
              </div>
            </div>
          )}
          <div className="h-[18svh]" />
        </div>
      </section>
    );
  }

  /* ---------- stage ---------- */
  if (variant === 'stage') {
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-hidden py-20 sm:py-28', className)}>
        <div ref={watch} className="relative">
          <div className="px-6"><Head eyebrow={eyebrow} title={title} description={description} action={action} /></div>
          <div className="relative mt-12 sm:mt-16">
            <Floor pattern={pat} setRef={setFloor} top="46%" tilt={70} />
            <div className="kit-container relative [perspective:1400px]">
              <ul ref={cam} className="grid grid-cols-1 gap-6 [transform-style:preserve-3d] sm:grid-cols-3" style={{ transform: 'rotateX(8deg) rotateY(0deg)' }}>
                {items.slice(0, 3).map((it, i) => (
                  <li key={i} className="[transform-style:preserve-3d]" style={{ transform: `translateZ(${i === 1 ? 40 : 0}px)` }}>
                    <div className="relative rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)]">
                      <span className="flex size-11 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
                        <Icon name={it.icon ?? 'sparkle'} size={20} />
                      </span>
                      <p className="mt-5 text-[18px] font-semibold text-foreground">{it.title}</p>
                      {it.text && <p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
                      {it.meta && <p className="mt-5 text-[13px] font-medium text-primary">{it.meta}</p>}
                    </div>
                    {/* a soft reflection on the floor */}
                    <div aria-hidden className="pointer-events-none mt-2 h-16 rounded-2xl bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--primary)_14%,transparent),transparent)] opacity-70 blur-[2px] max-sm:hidden" />
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </div>
      </section>
    );
  }

  /* ---------- bars ---------- */
  const max = Math.max(1, ...items.map((it) => it.value ?? 0));
  const bars = items.slice(0, 6);
  return (
    <section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
      <div ref={watch} className="kit-container">
        <Head eyebrow={eyebrow} title={title} description={description} />
        <div className="mt-10 grid grid-cols-1 items-center gap-10 sm:mt-14 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)]">
          <div aria-hidden className="relative mx-auto aspect-[1.35] w-full max-w-[40rem] [container-type:inline-size]">
            <div className="absolute left-1/2 top-[64%] [transform-style:preserve-3d]" style={{ width: '62cqw', height: '40cqw', transform: 'translate(-50%, -50%) rotateX(58deg) rotateZ(-34deg)' }}>
              <span
                className="absolute inset-[-8%] rounded-xl"
                style={{
                  backgroundImage: `linear-gradient(to right, color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px)`,
                  backgroundSize: '8% 12.5%',
                  maskImage: 'radial-gradient(closest-side, black 60%, transparent)',
                }}
              />
              {bars.map((it, i) => {
                const h = round(((it.value ?? 0) / max) * 46, 2);
                const s = seg(0.1 + i * 0.08, 0.7 + i * 0.05);
                const w = 100 / bars.length;
                const box = { left: `${round(i * w + w * 0.2, 2)}%`, width: `${round(w * 0.6, 2)}%`, top: '30%', height: '40%' } as const;
                const top = i === bars.length - 1 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 55%, var(--card))';
                return (
                  <div key={i} className="absolute [transform-style:preserve-3d]" style={box}>
                    {/* front, side and top of a solid bar */}
                    <span className="absolute inset-x-0 top-full origin-top" style={{ height: `${h}cqw`, background: `color-mix(in oklab, ${top} 78%, black)`, transform: `rotateX(90deg) scaleY(${s})` }} />
                    <span className="absolute inset-y-0 left-full origin-left" style={{ width: `${h}cqw`, background: `color-mix(in oklab, ${top} 62%, black)`, transform: `rotateY(-90deg) scaleX(${s})` }} />
                    <span className="absolute inset-0 rounded-[2px]" style={{ background: top, transform: `translateZ(calc(${h}cqw * ${s}))` }} />
                  </div>
                );
              })}
            </div>
          </div>
          <ol className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-1">
            {bars.map((it, i) => (
              <li key={i} className="flex items-baseline justify-between gap-3 rounded-xl border border-border bg-card px-4 py-3 shadow-[var(--highlight)]">
                <span className="flex min-w-0 items-center gap-2 text-[14px] text-muted-foreground">
                  <span className={cn('size-2.5 shrink-0 rounded-[3px]', i === bars.length - 1 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))]')} />
                  <span className="truncate">{it.title}</span>
                </span>
                <span className="shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{it.meta}</span>
              </li>
            ))}
          </ol>
        </div>
        {action && (
          <div className="mt-10 flex justify-center">
            <Button href={action.href} variant="secondary" icon="arrow-right">
              {action.label}
            </Button>
          </div>
        )}
      </div>
    </section>
  );
}

Frequent questions

How do I install Perspective floor?

With aiw add @ai-website/perspective-floor. The command puts components/ui/perspective-floor.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 Perspective floor have?

4 variants, Opening over a grid, Cards on a stage, Figures as bars and Offer above the horizon. You pick one with the variant prop, for example variant="stage".

Can an AI assistant build with Perspective floor?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Perspective floor with aiw_components and install it with aiw_add @ai-website/perspective-floor. Name the variant in your prompt, for example variant="grid", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard