Description list
Terms and values as a real dl, in stacked rows, two columns, a hairline grid, an inline meta line or a spec sheet with groups. Values copy, link and carry a status.
One of 4 components in Lists, trees and timelines
Variants
Details
What Description list brings along, read from the registry and the source.
- Category
- Lists, trees and timelines
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/description-list.tsx
- Brings along
- utilskiticoncopy-button
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DescriptionListProps interface from components/ui/description-list.tsx, with the comments from the code.
variantoptionalDescriptionListVariantstackedcolumnsgridinlinespec
itemsrequiredDescriptionItem[]titleoptionalstringA heading above the list
descriptionoptionalstringA line under the heading
actionoptionalReact.ReactNodeAn edit link or button at the end of the heading row
columnsoptional2 | 3 | 4grid: columns on large screens. Default 3.
visibleoptionalnumberspec and stacked: rows shown before "Show all"
Built-in texts
Description list writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| more | English Show all | German Alle zeigen |
| less | English Show fewer | German Weniger zeigen |
| copy | English Copy | German Kopieren |
Accessibility
Read from the source of the component.
- Sets aria-controls and aria-expanded, so screen readers announce purpose and state.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 287 lines
'use client';
import { useId, useState } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CopyButton } from '@/components/ui/copy-button';
/*
* Terms and their values, as a real <dl>. For detail pages, order and
* account summaries, product specs and the meta line under a heading.
* Five looks: stacked rows, two columns, a grid of tiles, an inline meta
* row and a spec sheet with groups. Values can be copied, linked, marked
* with a status, and long lists fold away behind "Show all".
*/
const TEXTS = {
en: { more: 'Show all', less: 'Show fewer', copy: 'Copy' },
de: { more: 'Alle zeigen', less: 'Weniger zeigen', copy: 'Kopieren' },
};
export type DescriptionTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
export interface DescriptionItem {
term: string;
value: string;
/** A second, quieter line under the value */
description?: string;
/** A small icon before the term */
icon?: IconName;
/** A copy button for the value */
copy?: boolean;
/** The value links here */
href?: string;
/** Shows the value as a status with a dot */
tone?: DescriptionTone;
/** spec: a group heading above this item */
group?: string;
/** Any node instead of the plain value, e.g. avatars or a progress bar */
node?: React.ReactNode;
}
export type DescriptionListVariant = 'stacked' | 'columns' | 'grid' | 'inline' | 'spec';
export interface DescriptionListProps {
variant?: DescriptionListVariant;
items: DescriptionItem[];
/** A heading above the list */
title?: string;
/** A line under the heading */
description?: string;
/** An edit link or button at the end of the heading row */
action?: React.ReactNode;
/** grid: columns on large screens. Default 3. */
columns?: 2 | 3 | 4;
/** spec and stacked: rows shown before "Show all" */
visible?: number;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TONE: Record<DescriptionTone, string> = {
neutral: 'bg-[color-mix(in_oklab,var(--foreground)_45%,transparent)]',
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-destructive',
info: 'bg-info',
};
function Value({ item, strong }: { item: DescriptionItem; strong?: boolean }) {
if (item.node) return <>{item.node}</>;
const text = item.tone ? (
<span className="inline-flex items-center gap-2">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', TONE[item.tone])} />
{item.value}
</span>
) : (
item.value
);
return (
<span className={cn('min-w-0 break-words', strong ? 'font-medium text-foreground' : 'text-foreground')}>
{item.href ? (
<a href={item.href} className="rounded-sm underline decoration-[color-mix(in_oklab,var(--foreground)_30%,transparent)] underline-offset-[3px] transition-colors hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-primary">
{text}
</a>
) : (
text
)}
</span>
);
}
function Term({ item, className }: { item: DescriptionItem; className?: string }) {
return (
<dt className={cn('flex items-center gap-2 text-[13px] text-muted-foreground', className)}>
{item.icon && <Icon name={item.icon} size={15} className="shrink-0 text-subtle-foreground" />}
{item.term}
</dt>
);
}
/** Value, its second line and the copy button, in one dd */
function Detail({ item, className, strong }: { item: DescriptionItem; className?: string; strong?: boolean }) {
return (
<dd className={cn('flex min-w-0 items-start justify-between gap-3 text-sm', className)}>
<span className="min-w-0">
<Value item={item} strong={strong} />
{item.description && <span className="mt-0.5 block text-[13px] text-muted-foreground">{item.description}</span>}
</span>
{item.copy && <CopyButton value={item.value} size="xs" className="-my-1 shrink-0 max-sm:-my-3 max-sm:size-11" />}
</dd>
);
}
/**
* Terms and values for detail pages, summaries, specs and meta lines.
* <DescriptionList title="Account" items={[{ term: 'Plan', value: 'Studio' }]} />
* <DescriptionList variant="columns" items={…} />
* <DescriptionList variant="grid" columns={4} items={…} />
* <DescriptionList variant="inline" items={…} />
* <DescriptionList variant="spec" visible={6} items={…} />
*/
export function DescriptionList({ variant = 'stacked', items, title, description, action, columns = 3, visible, labels, className }: DescriptionListProps) {
const t = useTexts(TEXTS, labels);
const [ref, inView] = useInView<HTMLDivElement>();
const [all, setAll] = useState(false);
const listId = useId();
const foldable = (variant === 'spec' || variant === 'stacked' || variant === 'columns') && visible !== undefined && items.length > visible;
const head = (title || action) && (
<div className={cn('flex items-start justify-between gap-4', variant === 'grid' || variant === 'inline' ? 'mb-4' : 'mb-2')}>
<div className="min-w-0">
{title && <h3 className="text-[15px] font-semibold text-foreground">{title}</h3>}
{description && <p className="mt-0.5 text-[13px] text-muted-foreground">{description}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
// Rows rise one after another once the list scrolls into view
const rise = (i: number) => ({
className: cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-2 opacity-0'),
style: { transitionDelay: inView ? `${Math.min(i, 10) * 45}ms` : '0ms' } as React.CSSProperties,
});
const folded = (i: number) => foldable && !all && i >= (visible ?? 0);
const more = foldable && (
<button
type="button"
aria-expanded={all}
aria-controls={listId}
onClick={() => setAll(!all)}
className={cn(variant === 'spec' ? 'ms-1.5' : '-ms-1.5', 'mt-2 inline-flex min-h-11 items-center gap-1 rounded-md px-1.5 text-[13px] font-medium text-foreground outline-none hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:min-h-8')}
>
{all ? t.less : `${t.more} (${items.length})`}
<Icon name="chevron-down" size={14} className={cn('transition-[rotate] duration-300 ease-[var(--ease-kit)]', all && 'rotate-180')} />
</button>
);
/* ---------- inline: the meta line under a heading ---------- */
if (variant === 'inline') {
return (
<div ref={ref} className={cn('w-full', className)}>
{head}
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:flex sm:flex-wrap sm:items-stretch sm:gap-0">
{items.map((item, i) => (
<div key={i} {...rise(i)} className={cn(rise(i).className, 'min-w-0 sm:px-6 sm:first:ps-0 sm:[&+&]:border-s sm:[&+&]:border-border')}>
<Term item={item} className="text-[12px]" />
<dd className="mt-1 flex items-center gap-1.5 text-sm">
<Value item={item} strong />
{item.copy && <CopyButton value={item.value} size="xs" className="-my-1 max-sm:-my-3 max-sm:size-11" />}
</dd>
</div>
))}
</dl>
</div>
);
}
/* ---------- grid: tiles on a hairline grid ---------- */
if (variant === 'grid') {
const cols = { 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-2 lg:grid-cols-3', 4: 'sm:grid-cols-2 lg:grid-cols-4' }[columns];
// Fill the last row with empty tiles, so the hairlines meet the edge everywhere and no grey block shows
const fillSm = (2 - (items.length % 2)) % 2;
const fillLg = (columns - (items.length % columns)) % columns;
const fillers = Array.from({ length: Math.max(fillSm, fillLg) }, (_, k) => cn('hidden bg-card', k < fillSm && 'sm:block', columns > 2 && (k < fillLg ? 'lg:block' : 'lg:hidden')));
return (
<div ref={ref} className={cn('w-full', className)}>
{head}
<dl className={cn('grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border', cols)}>
{items.map((item, i) => (
<div key={i} className="group/tile relative min-w-0 bg-card p-4 transition-colors duration-200 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))] sm:p-5">
<div {...rise(i)}>
<Term item={item} />
<Detail item={item} strong className="mt-1.5 text-[15px]" />
</div>
</div>
))}
{fillers.map((c, k) => (
<div key={`f${k}`} aria-hidden className={c} />
))}
</dl>
</div>
);
}
/* ---------- spec: groups with zebra rows ---------- */
if (variant === 'spec') {
// One dl per group: a heading inside a dl is not allowed, so it sits above its own list
const groups: { name?: string; rows: { item: DescriptionItem; i: number }[] }[] = [];
items.forEach((item, i) => {
const last = groups[groups.length - 1];
if (!last || (item.group && item.group !== last.name)) groups.push({ name: item.group ?? last?.name, rows: [{ item, i }] });
else last.rows.push({ item, i });
});
return (
<div ref={ref} id={listId} className={cn('w-full', className)}>
{head}
{groups.map((g, gi) => {
const rows = g.rows.filter((r) => !folded(r.i));
if (!rows.length) return null;
return (
<div key={gi}>
{g.name && <p className={cn('px-3 pb-2 text-[11px] font-medium uppercase tracking-[0.09em] text-subtle-foreground', gi > 0 ? 'pt-6' : 'pt-1')}>{g.name}</p>}
<dl className="grid grid-cols-[minmax(0,1fr)]">
{/* Zebra counts within its group, so every group starts on a light row */}
{rows.map(({ item, i }, k) => (
<div
key={i}
{...rise(i)}
className={cn(
rise(i).className,
'grid grid-cols-[minmax(0,1fr)] items-start gap-0.5 rounded-md px-3 py-2.5 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] sm:gap-4 starting:opacity-0',
k % 2 === 1 && 'bg-[color-mix(in_oklab,var(--foreground)_3.5%,transparent)]',
)}
>
<Term item={item} />
<Detail item={item} />
</div>
))}
</dl>
</div>
);
})}
{more}
</div>
);
}
/* ---------- columns: term left, value right ---------- */
if (variant === 'columns') {
return (
<div ref={ref} className={cn('w-full', className)}>
{head}
<dl id={listId} className="grid grid-cols-[minmax(0,1fr)] border-t border-border">
{items.map((item, i) =>
folded(i) ? null : (
<div
key={i}
{...rise(i)}
className={cn(rise(i).className, 'grid gap-1 border-b border-border py-3.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] sm:gap-6 sm:py-4 starting:opacity-0')}
>
<Term item={item} className="sm:pt-px" />
<Detail item={item} />
</div>
),
)}
</dl>
{more}
</div>
);
}
/* ---------- stacked: term above value ---------- */
return (
<div ref={ref} className={cn('w-full', className)}>
{head}
<dl id={listId} className="grid grid-cols-[minmax(0,1fr)] divide-y divide-border">
{items.map((item, i) =>
folded(i) ? null : (
<div key={i} {...rise(i)} className={cn(rise(i).className, 'py-3.5 starting:opacity-0')}>
<Term item={item} />
<Detail item={item} className="mt-1" strong />
</div>
),
)}
</dl>
{more}
</div>
);
}Frequent questions
How do I install Description list?
With aiw add @ai-website/description-list. The command puts components/ui/description-list.tsx into your project, the code is yours from then on. It brings along icon and copy-button. It needs no further npm packages.
Which variants does Description list have?
5 variants, Stacked, Two columns, Grid, Inline meta and Spec sheet. You pick one with the variant prop, for example variant="grid".
Can I translate the texts of Description list?
Yes. Description list carries 3 texts of its own in English and German, such as “Show all”, “Show fewer” and “Copy”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Description list?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Description list with aiw_components and install it with aiw_add @ai-website/description-list. Name the variant in your prompt, for example variant="columns", and the assistant writes your content straight into the props.
Can I adjust Description list without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching DescriptionList code to copy.