Zum Inhalt springen
UI Kit
Accent

Masonry-Layout

Ein Masonry für Bilder, Zitate, Notizen und Kennzahlen jeder Höhe. Ausgeglichene Spalten, geordnete Reihen, Filter oder Mehr laden.

4 VariantsEine von 5 Components in Layouts

Jede Höhe, eine ordentliche Wand

Ein Masonry-Layout stapelt Inhalte unterschiedlicher Höhe zu einer ordentlichen Wand. Dieses nimmt Bilder, Zitate, Notizen und Kennzahlen im selben Raster auf, und die geordnete Fassung hält die Lesereihenfolge von links nach rechts für Inhalte, bei denen die Reihenfolge zählt.

„Spalten“ ist das klassische Masonry in ausgeglichenen Spalten mit gemischten Karten, die beim Hereinscrollen aufsteigen. „Geordnete Reihen“ führt die Lesereihenfolge Reihe für Reihe von links nach rechts, jede Karte behält ihre Höhe. „Mit Filter“ ergänzt Chips mit Anzahl, die nach Thema filtern, der Rest schließt die Lücken und steigt neu auf. „Mehr laden“ zeigt eine erste Menge mit Fortschritt und einen Knopf für die nächste, der Fokus landet auf der ersten neuen Karte.

Nimm Spalten für Galerien und Moodboards, geordnete Reihen für News oder alles, was nach Datum sortiert ist. Filter helfen ab etwa zwanzig Inhalten. „Mehr laden“ hält lange Wände schnell und gibt ein Gefühl für den Fortschritt.

Variants

SpaltenNeu in 1.2

Das klassische Masonry in ausgeglichenen Spalten mit gemischten Karten, die beim Scrollen aufsteigen.

Geordnete ReihenNeu in 1.2

Die Lesereihenfolge läuft von links nach rechts, Reihe für Reihe, und jede Karte behält ihre Höhe.

Mit FilterNeu in 1.2

Chips mit Anzahl filtern die Karten nach Thema, der Rest schließt die Lücken und steigt neu auf.

Mehr ladenNeu in 1.2

Eine erste Auswahl mit Fortschritt und ein Knopf für die nächste, der Fokus landet auf der ersten neuen Karte.

Details

Was Masonry-Layout mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Layouts
Bereich
Layout
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/masonry-layout.tsx
Bringt mit
utilskiticonsectionreveal
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 6 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle MasonryLayoutProps aus components/ui/masonry-layout.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MasonryLayoutVariant

    columnsorderedfilterload-more

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsPflicht
    MasonryItem[]
  • columnsoptional
    2 | 3 | 4

    Columns on wide screens, 2 to 4

  • batchoptional
    number

    load-more: cards per batch

  • moreHrefoptional
    string

    load-more: a page with everything, used without JavaScript

Eingebaute Texte

Diese Texte schreibt Masonry-Layout selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 6 Texte anzeigen
allEnglisch AllDeutsch Alle
shownEnglisch {n} shownDeutsch {n} angezeigt
moreEnglisch Show {n} moreDeutsch {n} weitere zeigen
ofEnglisch {a} of {b}Deutsch {a} von {b}
filterEnglisch Filter by topicDeutsch Nach Thema filtern
openEnglisch OpenDeutsch Öffnen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle group.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • 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).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 308 Zeilen
components/ui/masonry-layout.tsx
'use client';

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';

/*
 * A masonry for cards of different heights: pictures, quotes, notes and
 * figures, as a pinboard, a journal or a wall of work.
 *
 *   columns   balanced columns, the classic masonry, read top to bottom
 *   ordered   the reading order stays left to right, row by row, while
 *             every card keeps its own height (measured rows)
 *   filter    chips above that filter the cards, the rest closes the gaps
 *   load-more a first batch and a button that adds the next, the focus
 *             lands on the first new card
 *
 * Without JavaScript columns and filter show everything, ordered falls back
 * to an even grid and load-more shows the first batch with a link.
 */

export type MasonryLayoutVariant = 'columns' | 'ordered' | 'filter' | 'load-more';

export interface MasonryItem {
  /** image: picture with caption. quote: a quote with cite. note: title and text. stat: a figure. */
  kind?: 'image' | 'quote' | 'note' | 'stat';
  title?: string;
  text?: string;
  /** stat: the figure */
  value?: string;
  /** image: caption under the picture, quote: who said it */
  caption?: string;
  /** Filter group, also shown as a small label */
  tag?: string;
  href?: string;
  /** image: the picture */
  media?: React.ReactNode;
  /** image: aspect ratio of the picture, e.g. 4/5 or 16/10 */
  aspect?: string;
}

const TEXTS = {
  en: { all: 'All', shown: '{n} shown', more: 'Show {n} more', of: '{a} of {b}', filter: 'Filter by topic', open: 'Open' },
  de: { all: 'Alle', shown: '{n} angezeigt', more: '{n} weitere zeigen', of: '{a} von {b}', filter: 'Nach Thema filtern', open: 'Öffnen' },
};
type Texts = (typeof TEXTS)['en'];

export interface MasonryLayoutProps {
  variant?: MasonryLayoutVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  items: MasonryItem[];
  /** Columns on wide screens, 2 to 4 */
  columns?: 2 | 3 | 4;
  /** load-more: cards per batch */
  batch?: number;
  /** load-more: a page with everything, used without JavaScript */
  moreHref?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
const fill = (s: string, v: Record<string, string | number>) => Object.entries(v).reduce((a, [k, x]) => a.replace(`{${k}}`, String(x)), s);

const COLS: Record<2 | 3 | 4, string> = { 2: 'sm:columns-2', 3: 'sm:columns-2 lg:columns-3', 4: 'sm:columns-2 lg:columns-3 xl:columns-4' };
const GRID: Record<2 | 3 | 4, string> = { 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-2 lg:grid-cols-3', 4: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' };

/* ------------------------------------------------------------------ */
/* One card                                                            */
/* ------------------------------------------------------------------ */

function Card({ it }: { it: MasonryItem }) {
  const kind = it.kind ?? (it.media ? 'image' : it.value ? 'stat' : 'note');
  const Wrap = it.href ? 'a' : 'div';
  const base = cn('group relative block overflow-hidden rounded-xl border border-border shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)]', it.href && 'hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0', it.href && focus);
  const tag = it.tag && <span className="text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{it.tag}</span>;

  if (kind === 'image')
    return (
      <Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-card')}>
        <span data-clip-ok className="relative block overflow-hidden bg-muted" style={{ aspectRatio: it.aspect ?? '4 / 3' }}>
          <span className="absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none [&>*]:size-full">{it.media}</span>
        </span>
        {(it.title || it.caption || it.tag) && (
          <span className="block border-t border-border px-4 py-3.5">
            {tag}
            {it.title && <span className="mt-0.5 block text-[15px] font-semibold leading-snug text-foreground">{it.title}</span>}
            {it.caption && <span className="mt-0.5 block text-[13px] text-muted-foreground">{it.caption}</span>}
          </span>
        )}
      </Wrap>
    );
  if (kind === 'quote')
    return (
      <Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-surface p-6')}>
        <Icon name="quote" size={20} className="text-[color-mix(in_oklab,var(--primary)_70%,var(--border-strong))]" />
        <span className="mt-4 block text-pretty text-[17px] font-medium leading-snug tracking-[-0.015em] text-foreground">{it.text}</span>
        {it.caption && <span className="mt-4 block text-[13px] text-muted-foreground">{it.caption}</span>}
      </Wrap>
    );
  if (kind === 'stat')
    return (
      <Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] p-6')}>
        {tag}
        <span className="mt-2 block text-[clamp(2.25rem,1.8rem+1.6vw,3rem)] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{it.value}</span>
        {it.title && <span className="mt-3 block text-[14.5px] font-medium text-foreground">{it.title}</span>}
        {it.text && <span className="mt-1 block text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{it.text}</span>}
      </Wrap>
    );
  return (
    <Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-card p-6')}>
      {tag}
      {it.title && <span className="mt-1.5 block text-balance text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground">{it.title}</span>}
      {it.text && <span className="mt-2 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</span>}
      {it.href && (
        <span className="mt-4 inline-flex items-center gap-1 text-[13px] font-medium text-foreground">
          <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover:translate-x-0.5" />
        </span>
      )}
    </Wrap>
  );
}

function Head({ p, children }: { p: MasonryLayoutProps; children?: React.ReactNode }) {
  if (!p.title && !p.eyebrow && !children) return null;
  return (
    <div className="mb-10 grid gap-6 sm:mb-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-12">
      <div className="max-w-[40rem]">
        {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
        {p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
        {p.description && <p className="mt-5 text-pretty text-lead text-muted-foreground">{p.description}</p>}
      </div>
      {children}
    </div>
  );
}

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

function Columns({ p, items }: { p: MasonryLayoutProps; items: MasonryItem[] }) {
  return (
    <ul className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
      {items.map((it, i) => (
        <Reveal as="li" key={(it.title ?? it.text ?? '') + i} delay={(i % 6) * 60} className="break-inside-avoid">
          <Card it={it} />
        </Reveal>
      ))}
    </ul>
  );
}

/** Row by row, with each card spanning as many 8 px rows as it is tall */
function Ordered({ p }: { p: MasonryLayoutProps }) {
  const list = useRef<HTMLUListElement>(null);
  const [spans, setSpans] = useState<number[] | null>(null);
  useLayoutEffect(() => {
    const el = list.current;
    if (!el) return;
    const measure = () => {
      const kids = Array.from(el.children) as HTMLElement[];
      setSpans(kids.map((k) => Math.ceil(((k.firstElementChild as HTMLElement | null)?.getBoundingClientRect().height ?? 0) / 8) + 2));
    };
    measure();
    const ro = new ResizeObserver(measure);
    Array.from(el.children).forEach((k) => k.firstElementChild && ro.observe(k.firstElementChild));
    return () => ro.disconnect();
  }, [p.items]);
  return (
    <ul ref={list} className={cn('grid grid-cols-[minmax(0,1fr)] gap-x-4', GRID[p.columns ?? 3], spans ? 'auto-rows-[8px]' : 'gap-y-4')}>
      {p.items.map((it, i) => (
        <Reveal as="li" key={(it.title ?? it.text ?? '') + i} delay={(i % 6) * 60} className="self-start" style={spans ? { gridRowEnd: `span ${spans[i] ?? 1}` } : undefined}>
          <div>
            <Card it={it} />
          </div>
        </Reveal>
      ))}
    </ul>
  );
}

function Filter({ p, t }: { p: MasonryLayoutProps; t: Texts }) {
  const tags = Array.from(new Set(p.items.map((i) => i.tag).filter(Boolean))) as string[];
  const [on, setOn] = useState<string | null>(null);
  const shown = p.items.filter((i) => !on || i.tag === on);
  return (
    <>
      <Head p={p}>
        <div role="group" aria-label={t.filter} className="kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 max-lg:kit-fade-end lg:mx-0 lg:flex-wrap lg:justify-end lg:overflow-visible lg:px-0">
          {[null, ...tags].map((tag) => {
            const active = on === tag;
            const count = tag ? p.items.filter((i) => i.tag === tag).length : p.items.length;
            return (
              <button
                key={tag ?? 'all'}
                type="button"
                aria-pressed={active}
                onClick={() => setOn(tag)}
                className={cn('inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-full border px-4 text-[13.5px] font-medium transition-[background-color,color,border-color] duration-200 sm:min-h-10', active ? 'border-foreground bg-foreground text-background' : 'border-border bg-background text-muted-foreground hover:border-border-strong hover:text-foreground', focus)}
              >
                {tag ?? t.all}
                <span className={cn('text-[12px] tabular-nums', active ? 'text-background/70' : 'text-subtle-foreground')}>{count}</span>
              </button>
            );
          })}
        </div>
      </Head>
      <p className="sr-only" aria-live="polite">
        {fill(t.shown, { n: shown.length })}
      </p>
      <ul key={on ?? 'all'} className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
        {shown.map((it, i) => (
          <li key={(it.title ?? it.text ?? '') + i} className="break-inside-avoid motion-safe:animate-[kit-rise_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}>
            <Card it={it} />
          </li>
        ))}
      </ul>
    </>
  );
}

function LoadMore({ p, t }: { p: MasonryLayoutProps; t: Texts }) {
  const batch = p.batch ?? 6;
  const [count, setCount] = useState(batch);
  const [hydrated, setHydrated] = useState(false);
  const first = useRef<HTMLLIElement>(null);
  const [focusAt, setFocusAt] = useState<number | null>(null);
  const locale = useLocale();
  useEffect(() => setHydrated(true), []);
  useEffect(() => {
    if (focusAt === null) return;
    const target = first.current?.querySelector<HTMLElement>('a, [tabindex]') ?? first.current;
    target?.focus();
  }, [focusAt]);
  const shown = p.items.slice(0, count);
  const rest = p.items.length - count;
  return (
    <>
      <Head p={p} />
      <ul className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
        {shown.map((it, i) => (
          <li
            key={(it.title ?? it.text ?? '') + i}
            ref={i === focusAt ? first : undefined}
            tabIndex={i === focusAt && !it.href ? -1 : undefined}
            className={cn('break-inside-avoid rounded-xl', focus, i >= batch && 'motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)_both]')}
            style={i >= batch ? { animationDelay: `${Math.min(i % batch, 8) * 50}ms` } : undefined}
          >
            <Card it={it} />
          </li>
        ))}
      </ul>
      <div className="mt-8 flex flex-col items-center gap-4">
        <div className="flex w-full max-w-[16rem] items-center gap-3 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
          <span className="h-1 flex-1 overflow-hidden rounded-full bg-track" aria-hidden>
            <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${Math.min(1, count / Math.max(1, p.items.length))})` }} />
          </span>
          {fill(t.of, { a: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(Math.min(count, p.items.length)), b: p.items.length })}
        </div>
        {rest > 0 &&
          (hydrated || !p.moreHref ? (
            <button
              type="button"
              onClick={() => {
                setFocusAt(count);
                setCount((c) => c + batch);
              }}
              className={cn('inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border border-border-strong bg-background px-5 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent active:scale-[0.98]', focus)}
            >
              <Icon name="plus" size={16} />
              {fill(t.more, { n: Math.min(batch, rest) })}
            </button>
          ) : (
            <a href={p.moreHref} className={cn('inline-flex min-h-11 items-center gap-2 rounded-lg border border-border-strong bg-background px-5 text-[14px] font-medium text-foreground', focus)}>
              {fill(t.more, { n: rest })}
            </a>
          ))}
      </div>
    </>
  );
}

/**
 * A masonry of cards with pictures, quotes, notes and figures: balanced
 * columns, an ordered grid with measured rows, filter chips or load more.
 *   <MasonryLayout variant="columns" items={[{ kind: 'image', media, aspect: '4/5', title }, { kind: 'quote', text, caption }]} />
 */
export function MasonryLayout(props: MasonryLayoutProps) {
  const { variant = 'columns', className, labels } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      {variant === 'filter' ? (
        <Filter p={props} t={t} />
      ) : variant === 'load-more' ? (
        <LoadMore p={props} t={t} />
      ) : (
        <>
          <Head p={props} />
          {variant === 'ordered' ? <Ordered p={props} /> : <Columns p={props} items={props.items} />}
        </>
      )}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Masonry-Layout?

Mit aiw add @ai-website/masonry-layout. Der Befehl legt die Datei components/ui/masonry-layout.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, section und reveal. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Masonry-Layout?

4 Varianten, Spalten, Geordnete Reihen, Mit Filter und Mehr laden. Du wählst sie mit der Prop variant, zum Beispiel variant="ordered".

Kann ich die Texte von Masonry-Layout übersetzen?

Ja. Masonry-Layout bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Alle“, „Nach Thema filtern“ und „Öffnen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Masonry-Layout einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Masonry-Layout mit aiw_components und installieren sie mit aiw_add @ai-website/masonry-layout. Nenn im Prompt die Variante, etwa variant="columns", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert