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.
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
Details
What Floating panels brings along, read from the registry and the source.
- Category
- 3D scenes
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/floating-panels.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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'>.
variantoptionalFloatingPanelsVariantfeaturesshelforbitsplit
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringpanelsrequiredFloatingPanel[]mediaoptionalReact.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
'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.