GrDescriptionList

Package: @feugene/granularitycoreGroup: data

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: flow lets the pairs run as a line rather than as a column.

When to take something else

NeedTake
Uniform rows of “heading and subheading”GrList
A single metric, largeGrStatistic
A set of objects in columns, with sortingGrDataTable
A form section with a headingGrFormSection
The values are editableGrFormField

`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…

Code
<GrDescriptionList />

Install

npm i @feugene/granularity

Import

import { GrDescriptionList } from '@feugene/granularity/components/GrDescriptionList'

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefined
dividedboolean | undefinedundefinedSeparate the pairs with rules. It does not apply in `flow`.
emptyTextstring | undefinedundefinedWhat to print an empty value with.
columnsGrDescriptionColumns | undefinedundefinedThe columns of the grid. They do not apply in `flow`: a line has no columns.
layoutGrDescriptionLayout | undefinedundefined`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).
labelWidthstring | undefinedundefinedThe width of the column of labels with `inline`.
stackBelownumber | undefinedundefinedBelow 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.
densityGrDescriptionDensity | undefinedundefined
itemsrequiredreadonly GrDescriptionItem[]

Slots

SlotTypeDescription
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

Basic
<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

Layout
<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>

Component documentationAll components