Skip to content
UI Kit
Accent color

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

5 variants

Variants

Stacked

Term above value, for narrow side panels like an order summary. A status dot, a second line, a copy button that turns into a check and a link to the invoice.

Open in Studio

Two columns

Term left, value right on hairlines, one column on phones. For profile and settings pages, long lists fold behind Show all.

Open in Studio

Grid

Key facts as tiles on a hairline grid, with icon and a quiet second line. Fits listings, case studies and product pages, the tiles rise in as they scroll into view.

Open in Studio

Inline meta

The line of facts under a page heading, divided by hairlines. On phones it becomes a two column grid, the invoice number copies with one tap.

Open in Studio

Spec sheet

Grouped specs with soft zebra rows for product pages. The first rows are shown, Show all unfolds the rest, and new rows fade in.

Open in Studio

Details

What Description list brings along, read from the registry and the source.

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

Props

The DescriptionListProps interface from components/ui/description-list.tsx, with the comments from the code.

  • variantoptional
    DescriptionListVariant

    stackedcolumnsgridinlinespec

  • itemsrequired
    DescriptionItem[]
  • titleoptional
    string

    A heading above the list

  • descriptionoptional
    string

    A line under the heading

  • actionoptional
    React.ReactNode

    An edit link or button at the end of the heading row

  • columnsoptional
    2 | 3 | 4

    grid: columns on large screens. Default 3.

  • visibleoptional
    number

    spec 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
moreEnglish Show allGerman Alle zeigen
lessEnglish Show fewerGerman Weniger zeigen
copyEnglish CopyGerman 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
components/ui/description-list.tsx
'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.

Often searched

Jump to

Copied to the clipboard