Schwebende Paneele
Paneele, die im Raum schweben und beim Scrollen nacheinander nach vorn kommen. Das vordere dreht sich zu dir, die anderen warten im Raum. Ohne JavaScript stehen sie in einem ruhigen Raster.
Immer ein Paneel nach vorn
Schwebende Paneele warten im Raum und kommen beim Scrollen nacheinander nach vorn. Das vordere dreht sich zu dir, die anderen warten im Raum, aus einer Liste von Features wird so eine Abfolge mit klarem Fokus. Ohne JavaScript stehen sie in einem ruhigen Raster.
„Wolke aus Funktionen“ hält Paneele in einer lockeren Wolke weit hinten und holt jedes der Reihe nach in die Mitte. Bei „Paneele im Regal“ stehen sie abgewandt wie Bücher, das aktive gleitet nach vorn, mit einem Bildschirm obenauf. „Umlaufbahn um die Überschrift“ dreht einen Ring aus Paneelen mit dem Scroll und bringt jedes unter der Überschrift nach vorn. „Schritte mit Säule“ listet links die Schritte, rechts schwebt das passende Paneel nach vorn.
Vier bis sechs Paneele passen zu einer Feature-Tour, jedes mit Titel und einer Zeile. Nimm die Säule, wenn die Schritte mehr Text brauchen. Die Umlaufbahn ist die verspielteste der vier und passt zu einem Produktstart.
Variants
Details
Was Schwebende Paneele mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Szenen
- Bereich
- Visuals
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/floating-panels.tsx
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FloatingPanelsProps aus components/ui/floating-panels.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalFloatingPanelsVariantfeaturesshelforbitsplit
eyebrowoptionalstringtitlePflichtReact.ReactNodedescriptionoptionalstringpanelsPflichtFloatingPanel[]mediaoptionalReact.ReactNode[]A picture on each panel (top), optional
actionoptional{ label: string; href: string }
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 243 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Schwebende Paneele?
Mit aiw add @ai-website/floating-panels. Der Befehl legt die Datei components/ui/floating-panels.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, icon, reveal und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Schwebende Paneele?
4 Varianten, Wolke aus Funktionen, Paneele im Regal, Umlaufbahn um die Überschrift und Schritte mit Säule. Du wählst sie mit der Prop variant, zum Beispiel variant="shelf".
Kann ein KI-Assistent Schwebende Paneele einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schwebende Paneele mit aiw_components und installieren sie mit aiw_add @ai-website/floating-panels. Nenn im Prompt die Variante, etwa variant="features", dann schreibt der Assistent deine Inhalte direkt in die Props.