Skip to content
UI Kit
Accent color

Floating panels

Panels that float in space and come forward one after another while the page scrolls. The one in front turns to face you, the others wait in the room. Without JavaScript they stand in a calm grid.

4 variantsOne of 6 components in 3D scenes

One panel steps forward at a time

Floating panels wait in space and come forward one after another while the page scrolls. The one in front turns to face you while the others wait in the room, so a list of features becomes a sequence with a clear focus. Without JavaScript they stand in a calm grid.

Cloud of features keeps panels in a loose cloud far back and brings each one to the middle in turn. Panels on a shelf stand side by side like books turned away, the active one slides forward with a screen on top. Orbit around the heading turns a ring of panels with the scroll and brings each to the front below the heading. Steps with a column lists steps on the left while the matching panel floats forward on the right.

Four to six panels suit a feature tour, each with a title and one line. Use the column version when the steps need more text. The orbit is the most playful of the four and fits a product launch.

Variants

Cloud of featuresNew in 1.2

Feature panels wait in a loose cloud far back, each comes forward to the middle in turn and returns to its place.

Panels on a shelfNew in 1.2

Panels stand side by side like books on a shelf, turned away. The active one slides forward and turns to face you, with a screen on top.

Orbit around the headingNew in 1.2

Panels on a ring in space, the ring turns with the scroll and brings each panel to the front below the heading.

Steps with a columnNew in 1.2

Steps on the left, the matching panel floats forward in a column on the right while the others tip away above and below.

Details

What Floating panels 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/floating-panels.tsx
Brings along
utilskitsectionbuttoniconrevealscroll-scene
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

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

  • variantoptional
    FloatingPanelsVariant

    featuresshelforbitsplit

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode
  • descriptionoptional
    string
  • panelsrequired
    FloatingPanel[]
  • mediaoptional
    React.ReactNode[]

    A picture on each panel (top), optional

  • actionoptional
    { label: string; href: string }

Accessibility

Read from the source of the component.

  • Sets aria-live, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • 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, 243 lines
components/ui/floating-panels.tsx
'use client';

import { useCallback, useEffect, useRef, useState } 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 { scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';

/*
 * Panels that float in space and come forward one after another while the page scrolls. The
 * section stays in place, the panel in turn moves to the front and turns to face the viewer, the
 * others wait in the room around it. Only transform and opacity are written, straight onto each
 * panel, and only the panel in front is reachable with the keyboard; tabbing on scrolls the scene.
 *
 * Without JavaScript and with reduced motion the panels stand in a calm grid.
 *
 *   features  panels in a loose cloud far back, each one comes forward to the middle
 *   shelf     panels standing in a row like books on a shelf, the active one turns to the front
 *   orbit     panels on a ring around the heading, the ring turns with the scroll
 *   split     steps on the left, the matching panel floats forward in a column on the right
 */

export type FloatingPanelsVariant = 'features' | 'shelf' | 'orbit' | 'split';

export interface FloatingPanel {
  title: string;
  text?: string;
  icon?: IconName;
  /** a short line, e.g. a figure or a step */
  meta?: string;
}

export interface FloatingPanelsProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: FloatingPanelsVariant;
  eyebrow?: string;
  title: React.ReactNode;
  description?: string;
  panels: FloatingPanel[];
  /** A picture on each panel (top), optional */
  media?: React.ReactNode[];
  action?: { label: string; href: string };
}

type Pose = { x: number; y: number; z: number; ry: number; rx: number; o: number };

/** Places in the cloud, share of the half stage (x, y) and depth */
const CLOUD = [
  { x: -0.62, y: -0.42, z: -700, ry: 22 },
  { x: 0.6, y: -0.36, z: -820, ry: -20 },
  { x: -0.7, y: 0.4, z: -900, ry: 18 },
  { x: 0.66, y: 0.44, z: -760, ry: -24 },
  { x: 0, y: -0.62, z: -1000, ry: 0 },
  { x: 0.05, y: 0.66, z: -1050, ry: 6 },
];

function pose(variant: FloatingPanelsVariant, d: number, n: number, w: number, h: number, i: number): Pose {
  const k = Math.min(1, w / 1280);
  const ad = Math.abs(d);
  const near = smooth(1 - Math.min(1, ad));
  if (variant === 'features') {
    const c = CLOUD[i % CLOUD.length];
    const narrow = w < 640 ? 0.4 : 1;
    return {
      x: (1 - near) * c.x * w * 0.5 * narrow,
      y: (1 - near) * c.y * h * 0.32,
      z: (1 - near) * c.z,
      ry: (1 - near) * c.ry,
      rx: 0,
      // On a phone the cloud sits close together, so the panels in the back stay quieter there
      o: (w < 640 ? 0.16 : 0.32) + near * (w < 640 ? 0.84 : 0.68),
    };
  }
  if (variant === 'shelf') {
    const s = Math.sign(d) * Math.min(1, ad);
    const ks = Math.max(0.58, k);
    return { x: (s * 340 + (d - s) * 100) * ks, y: 0, z: -Math.min(ad, 1) * 260 - Math.max(0, ad - 1) * 60, ry: -s * 58, rx: 0, o: ad > 3.2 ? 0 : 1 - Math.min(1, ad) * 0.45 - Math.max(0, ad - 1) * 0.2 };
  }
  if (variant === 'orbit') {
    const a = (d / n) * Math.PI * 2;
    const r = 520 * Math.max(0.55, k);
    return { x: Math.sin(a) * r, y: 0, z: Math.cos(a) * r - r, ry: Math.max(-70, Math.min(70, ((a * 180) / Math.PI) * 0.55)), rx: 0, o: Math.cos(a) > -0.2 ? 0.35 + 0.65 * Math.max(0, Math.cos(a)) : 0.2 };
  }
  // split
  return { x: 0, y: d * 150 * Math.max(0.7, k), z: -ad * 280, ry: 0, rx: -d * 16, o: ad > 2.4 ? 0 : 1 - ad * 0.38 };
}

function Panel({ panel, media, index }: { panel: FloatingPanel; media?: React.ReactNode; index: number }) {
  return (
    <div className="w-[min(78vw,22rem)] lg:w-[25rem] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
      {media && <div className="aspect-[16/9] overflow-hidden bg-surface [&>*]:size-full">{media}</div>}
      <div className="p-6">
        <div className="flex items-center justify-between gap-3">
          <span className="flex size-10 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
            <Icon name={panel.icon ?? 'sparkle'} size={19} />
          </span>
          <span className="text-[13px] font-medium tabular-nums text-muted-foreground">{panel.meta ?? String(index + 1).padStart(2, '0')}</span>
        </div>
        <h3 className="mt-5 text-[19px] font-semibold text-foreground">{panel.title}</h3>
        {panel.text && <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{panel.text}</p>}
      </div>
    </div>
  );
}

function Head({ eyebrow, title, description, center = true, small = false }: { eyebrow?: string; title: React.ReactNode; description?: string; center?: boolean; small?: boolean }) {
  return (
    <div className={cn('max-w-[40rem]', center && 'mx-auto text-center')}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className={cn('text-balance font-semibold text-foreground', small ? 'text-h2' : 'text-h1')}>{title}</h2>
      {description && <p className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
    </div>
  );
}

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

/** Panels that float in space and come forward in turn while the page scrolls. */
export function FloatingPanels({ variant = 'features', eyebrow, title, description, panels, media = [], action, className, onFocus, ...rest }: FloatingPanelsProps) {
  const live = useLive();
  const els = useRef<(HTMLElement | null)[]>([]);
  const stage = useRef<HTMLDivElement | null>(null);
  const size = useRef({ w: 1280, h: 800 });
  const last = useRef(0);
  const n = panels.length;
  const [front, setFront] = useState(0);
  const frontRef = useRef(0);
  const pic = (i: number) => (media.length ? media[i % media.length] : undefined);

  const apply = useCallback(
    (p: number) => {
      last.current = p;
      const { w, h } = size.current;
      const a = smooth(span(p, 0.06, 0.94)) * (n - 1);
      els.current.forEach((el, i) => {
        if (!el) return;
        const ps = pose(variant, i - a, n, w, h, i);
        el.style.transform = `translate(-50%, -50%) translate3d(${round(ps.x, 1)}px, ${round(ps.y, 1)}px, ${round(ps.z, 1)}px) rotateY(${round(ps.ry, 2)}deg) rotateX(${round(ps.rx, 2)}deg)`;
        el.style.opacity = String(round(ps.o, 3));
        el.style.zIndex = String(100 - Math.round(Math.abs(i - a) * 10));
      });
      const f = Math.round(a);
      if (f !== frontRef.current) {
        frontRef.current = f;
        setFront(f);
      }
    },
    [variant, n],
  );
  const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });

  useEffect(() => {
    if (!live) return;
    const el = stage.current;
    if (!el) return;
    const measure = () => {
      size.current = { w: el.clientWidth, h: el.clientHeight };
      apply(last.current);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [live, apply]);

  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>
  );

  if (!live) {
    return (
      <section {...rest} onFocus={onFocus} className={cn('relative py-20 sm:py-28', className)}>
        <div className="kit-container">
          <Reveal>
            <Head eyebrow={eyebrow} title={title} description={description} small />
          </Reveal>
          <ul className="mt-12 grid grid-cols-1 justify-items-center gap-6 sm:mt-16 md:grid-cols-2 lg:grid-cols-3">
            {panels.map((pn, i) => (
              <li key={i}>
                <Panel panel={pn} media={pic(i)} index={i} />
              </li>
            ))}
          </ul>
          {actionEl && <div className="mt-12 flex justify-center">{actionEl}</div>}
        </div>
      </section>
    );
  }

  const onSceneFocus = (e: React.FocusEvent<HTMLElement>) => {
    onFocus?.(e);
    const target = e.target as Node;
    const i = els.current.findIndex((el) => el?.contains(target));
    if (i >= 0) scrollToProgress(track.current, 0.06 + (i / Math.max(1, n - 1)) * 0.88);
  };

  const split = variant === 'split';
  const steps = (
    <ol className="mt-8 grid gap-1">
      {panels.map((pn, i) => (
        <li key={i} className={cn('flex items-center gap-3 rounded-lg px-3 py-2.5 text-[15px] transition-colors duration-500', i === front ? 'bg-card font-semibold text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground')}>
          <span className={cn('flex size-7 shrink-0 items-center justify-center rounded-md text-[12px] font-semibold tabular-nums transition-colors duration-500', i === front ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)]')}>{i + 1}</span>
          {pn.title}
        </li>
      ))}
    </ol>
  );

  return (
    <section {...rest} onFocus={onSceneFocus} className={cn('relative overflow-x-clip', className)}>
      <div ref={track} className="relative" style={{ height: `${round((n * 0.8 + 1) * 100, 1)}svh` }}>
        <div ref={stage} data-clip-ok className="sticky top-0 h-svh overflow-clip">
          <div className={cn('kit-container relative grid h-full', split ? 'grid-cols-1 grid-rows-[auto_minmax(0,1fr)] md:grid-rows-1 md:items-center md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]' : 'grid-rows-[auto_minmax(0,1fr)]')}>
            <div className={cn('relative z-20', split ? 'pt-[clamp(1.5rem,6svh,3rem)] md:pt-0' : 'pt-[clamp(2rem,8svh,5rem)]', variant === 'orbit' && 'pointer-events-none')}>
              <Head eyebrow={eyebrow} title={title} description={variant === 'orbit' ? undefined : description} center={!split} small />
              {split && <div className="max-md:hidden">{steps}</div>}
              {split && actionEl && <div className="mt-8 max-md:hidden">{actionEl}</div>}
            </div>
            <div className="relative h-full [perspective:1100px] [perspective-origin:50%_50%]">
              <div className={cn('absolute inset-0 [transform-style:preserve-3d]', variant === 'orbit' && 'top-[8%]')}>
                {panels.map((pn, i) => (
                  <div key={i} ref={(el) => void (els.current[i] = el)} className="absolute left-1/2 top-1/2 will-change-transform" inert={i !== front ? true : undefined} aria-hidden={i !== front}>
                    <Panel panel={pn} media={pic(i)} index={i} />
                  </div>
                ))}
              </div>
            </div>
          </div>
          {/* the step counter for screen readers and a small one for the eye */}
          <p aria-live="polite" className="sr-only">
            {panels[front]?.title}
          </p>
        </div>
      </div>
      {actionEl && <div className={cn('kit-container flex justify-center pb-20 sm:pb-28', split && 'md:hidden')}>{actionEl}</div>}
    </section>
  );
}

Frequent questions

How do I install Floating panels?

With aiw add @ai-website/floating-panels. The command puts components/ui/floating-panels.tsx into your project, the code is yours from then on. It brings along section, button, icon, reveal and scroll-scene. It needs no further npm packages.

Which variants does Floating panels have?

4 variants, Cloud of features, Panels on a shelf, Orbit around the heading and Steps with a column. You pick one with the variant prop, for example variant="shelf".

Can an AI assistant build with Floating panels?

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

Often searched

Jump to

Copied to the clipboard