Masonry-Layout
Ein Masonry für Bilder, Zitate, Notizen und Kennzahlen jeder Höhe. Ausgeglichene Spalten, geordnete Reihen, Filter oder Mehr laden.
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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MasonryLayoutProps aus components/ui/masonry-layout.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMasonryLayoutVariantcolumnsorderedfilterload-more
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsPflichtMasonryItem[]columnsoptional2 | 3 | 4Columns on wide screens, 2 to 4
batchoptionalnumberload-more: cards per batch
moreHrefoptionalstringload-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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| all | Englisch All | Deutsch Alle |
| shown | Englisch {n} shown | Deutsch {n} angezeigt |
| more | Englisch Show {n} more | Deutsch {n} weitere zeigen |
| of | Englisch {a} of {b} | Deutsch {a} von {b} |
| filter | Englisch Filter by topic | Deutsch Nach Thema filtern |
| open | Englisch Open | Deutsch Ö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
'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.