GrDescriptionList
Lists label-value pairs as a real description list, values aligned in a column.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the card of an object — the summary of an order, a profile, a document: labels on the left, values in a column on the right;
- technical details — identifiers, hashes, MIME types, versions: a long value wraps instead of pushing the layout apart;
- some of the values are not text — a status badge, a relative time, a link are substituted with a slot on a particular pair;
- there are many pairs — up to four columns fold a long list without tearing the pairs apart;
- metadata as a caption to something else — “Messages: 3 · Created: 12.04”
inside a list item:
flowlets the pairs run as a line rather than as a column.
When to take something else
| Need | Take |
|---|---|
| Uniform rows of “heading and subheading” | GrList |
| A single metric, large | GrStatistic |
| A set of objects in columns, with sorting | GrDataTable |
| A form section with a heading | GrFormSection |
| The values are editable | GrFormField |
`dl > div > dt + dd`, not a `<div>` that merely looks alike
A pair is wrapped into a <div>: such nesting is valid by HTML5 and is needed for the
layout. That is the very reason the component exists — hand-written markup twice
produced a <dl> with bare <div> elements inside. It looked like a list, but it was
not a pair of terms: neither for a parser nor for a screen reader was there a property
or a value there.
An empty value is printed rather than skipped
null, undefined and '' give a dash (emptyText, the #empty slot), but the row
stays. “The field exists, the value does not” and “the field does not exist” are
different statements; a dropped row breaks both the alignment and the reading.
Zero does not count as emptiness: 0 is a value.
The layout gives in to the width of the container
inline keeps the labels in a column of labelWidth, otherwise the values shift on
every row by the length of the label — exactly what the column is there for.
stackBelow (in pixels) switches the layout to stacked when the container is
narrower than the threshold: in a narrow column a fixed label squeezes the value down
to one letter per line. It is the container that is measured, not the viewport —
pairs live in a narrow card on a wide screen too. Before mounting, the layout that was
set applies, so the server render is stable.
The threshold applies only to inline: the column of labels is the only thing that
breaks in a narrow container. flow wraps by itself, and there is nothing to replace
it with.
columns lays the pairs out with a grid rather than with column-count: a pair is a
grid item and is not torn between columns.
`columns` is a ceiling, not an order
The number of columns is chosen by CSS from the width of the container:
columns: 4 means “up to four”. If there is room for four, there will be four; if
there is room for two, there will be two; and in a column of 290px there will be one.
Media queries are unsuitable here in principle: they measure the screen, while the list lives in a card. On a wide monitor two columns would switch on inside a narrow one, a label of fixed width would eat almost all of the room, and the value would wrap character by character — “30” printed as “3” and “0” on two lines. The number shown becomes wrong, and that is no longer a matter of taste.
The threshold below which a column is not divided is
--gr-description-list-column-min (12rem by default). Everything is computed in pure
CSS: no measurements, no JS, no divergence between the server and the client.
With inline the threshold is higher: the label and the value stand side by side,
so the column has to hold both — labelWidth plus
--gr-description-list-value-min (5rem). Without that addition a column as wide as a
single label leaves the value a few pixels, and it is the value that then wraps
character by character: the same defect, only in other numbers. In stacked and flow
the width of the label does not enter the calculation — the value is under it there,
not beside it.
`flow` — metadata as a line rather than as a column
inline and stacked both put the pairs one under another. When the pairs are short
and serve as a caption to something else — “Messages: 3 · Created: 12.04 · Last:
yesterday” inside a list item — a column is not what is needed: what is needed is a
line that flows across the width and wraps.
<GrDescriptionList :items="meta" layout="flow" />
The root is not a grid here, so columns, divided and labelWidth do not apply: a
line has neither columns nor a row that can be marked off with a rule.
The semantics does not suffer in the process — it is still a real <dl> with dt/dd
pairs, not text with colons.
The tone belongs to the value only
A red label reads as “the field is broken”, although the problem is with the quantity.
That is why the tone of a pair colours the <dd> and does not touch the <dt>.
The slots are named after `name`
#value-<name> and #label-<name> work for pairs that have a name set: a GrBadge,
a relative time and links regularly stand in the pairs. Pairs without a name remain
strings — the key exists precisely so that a slot addresses one pair rather than all of
them at once.
Limits
The component does not edit the values, does not sort the pairs and does not group them
into sections. Sections are a GrCard with a title around several lists.
Playground 5
Loading…
<GrDescriptionList />Install
npm i @feugene/granularityImport
import { GrDescriptionList } from '@feugene/granularity/components/GrDescriptionList'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
divided | boolean | undefined | undefined | Separate the pairs with rules. It does not apply in `flow`. |
emptyText | string | undefined | undefined | What to print an empty value with. |
columns | GrDescriptionColumns | undefined | undefined | The columns of the grid. They do not apply in `flow`: a line has no columns. |
layout | GrDescriptionLayout | undefined | undefined | `inline` — the label in a column on the left; `stacked` — above the value; `flow` — the pairs run along the line and wrap (metadata inside a list item). |
labelWidth | string | undefined | undefined | The width of the column of labels with `inline`. |
stackBelow | number | undefined | undefined | Below which width of the container (in px) `inline` switches to `stacked`. In a narrow column a fixed label squeezes the value down to one letter per line. |
density | GrDescriptionDensity | undefined | undefined | — |
itemsrequired | readonly GrDescriptionItem[] | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
empty | { item: GrDescriptionItem; } | The empty state of a value — when an item has nothing to show. |
Examples 2
Object summary as a real <dl>
- Status
- Active
- Created
- 17 August 2026
- Owner
- billing-team
- X-Request-Id
- req_01HXQZ8K3M7N2P4R6T8V0W1Y3Z
- Archived at
- —
<script setup lang="ts">
import type { GrDescriptionItem } from '@feugene/granularity'
import { GrBadge, GrDescriptionList } from '@feugene/granularity'
const items: GrDescriptionItem[] = [
{ label: 'Status', value: 'Active', name: 'status' },
{ label: 'Created', value: '17 August 2026' },
{ label: 'Owner', value: 'billing-team' },
{ label: 'X-Request-Id', value: 'req_01HXQZ8K3M7N2P4R6T8V0W1Y3Z' },
{ label: 'Archived at', value: null },
]
</script>
<template>
<GrDescriptionList :items="items" label-width="9rem" divided>
<!-- Слот адресуется по `name` пары: в парах регулярно стоят бейджи,
относительное время и ссылки, а не только строки. -->
<template #value-status>
<GrBadge tone="success">
Active
</GrBadge>
</template>
</GrDescriptionList>
</template>Inline, stacked, flow and columns
- Plan
- Business
- Seats
- 48
- MIME
- application/pdf
- Size
- 2.4 MB
- Checksum
- sha256:9f2b1c…
- Retention
- 90 days
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDescriptionItem, GrDescriptionColumns, GrDescriptionLayout } from '@feugene/granularity'
import { GrDescriptionList, GrSegmented } from '@feugene/granularity'
const items: GrDescriptionItem[] = [
{ label: 'Plan', value: 'Business' },
{ label: 'Seats', value: 48 },
{ label: 'MIME', value: 'application/pdf' },
{ label: 'Size', value: '2.4 MB' },
{ label: 'Checksum', value: 'sha256:9f2b1c…' },
{ label: 'Retention', value: '90 days' },
]
const layout = ref<GrDescriptionLayout>('inline')
const columns = ref<GrDescriptionColumns>(2)
/**
* Ширина контейнера, а не окна: колонки считает CSS от неё. Сузьте — и лишние
* колонки схлопнутся сами, не дожидаясь смены брейкпоинта вьюпорта.
*/
const width = ref(680)
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="layout"
:options="[
{ value: 'inline', label: 'inline' },
{ value: 'stacked', label: 'stacked' },
{ value: 'flow', label: 'flow' },
]"
size="sm"
/>
<!--
Колонки принадлежат сетке, поэтому в `flow` переключатель не у дел:
строка раскладывает пары по ширине и переносит их сама.
-->
<GrSegmented
v-model="columns"
:options="[
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
]"
:disabled="layout === 'flow'"
size="sm"
/>
<!--
Ширина контейнера, а не окна. Колонки считает CSS от неё: `columns` задаёт
потолок, а сколько их встанет на самом деле — решает место. Сузьте до
290px, и две колонки схлопнутся в одну, хотя экран остался широким.
-->
<label class="flex items-center gap-3 text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Ширина контейнера
<input v-model.number="width" type="range" min="240" max="680" step="10" class="w-48">
<span class="tabular-nums">{{ width }}px</span>
</label>
<!--
`stackBelow` меряет контейнер и переключает только раскладку подписей:
фиксированная подпись в узкой колонке выжимает значение в букву на строку.
-->
<div :style="{ width: `${width}px`, maxWidth: '100%' }" class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3">
<GrDescriptionList :items="items" :layout="layout" :columns="columns" :stack-below="420" />
</div>
</div>
</template>