Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 6 Components in 3D-Szenen

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

Wolke aus FunktionenNeu in 1.2

Funktionspaneele warten in einer lockeren Wolke weit hinten, jedes kommt der Reihe nach in die Mitte und kehrt an seinen Platz zurück.

Paneele im RegalNeu in 1.2

Paneele stehen nebeneinander wie Bücher im Regal, abgewandt. Das aktive gleitet nach vorn und dreht sich zu dir, mit einem Bildschirm obenauf.

Umlaufbahn um die ÜberschriftNeu in 1.2

Paneele auf einem Ring im Raum, der Ring dreht sich mit dem Scroll und bringt jedes Paneel unter der Überschrift nach vorn.

Schritte mit SäuleNeu in 1.2

Schritte links, das passende Paneel schwebt rechts in einer Säule nach vorn, die anderen kippen oben und unten weg.

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
Bringt mit
utilskitsectionbuttoniconrevealscroll-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

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.

  • variantoptional
    FloatingPanelsVariant

    featuresshelforbitsplit

  • eyebrowoptional
    string
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    string
  • panelsPflicht
    FloatingPanel[]
  • mediaoptional
    React.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
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>
  );
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert