Fact list
The facts of a record as terms and values: identifiers on a detail page, the summary of a customer in a side rail.
- Source
- API
- Payment ID
- pay_8f2a4c1e
- Merchant reference
- ORD-2026-0912
- Processing reference
- CP-77A19
- External ID
- Not set
- Description
- Two tickets for the 14 September show, row F.
import { FactList, type Fact } from '@/components/fact-list'
import { Badge } from '@/components/badge'
const facts: Fact[] = [
{ label: 'Source', value: <Badge color="blue">API</Badge> },
{ label: 'Payment ID', value: 'pay_8f2a4c1e', mono: true, copy: true },
{ label: 'Merchant reference', value: 'ORD-2026-0912', mono: true, copy: true },
{ label: 'Processing reference', value: 'CP-77A19', mono: true, copy: true },
{ label: 'External ID', value: null, mono: true, copy: true },
{ label: 'Description', value: 'Two tickets for the 14 September show, row F.', span: true },
]
export default function Example() {
return <FactList facts={facts} />
}<script setup lang="ts">
import Badge from '@/components/badge.vue'
import FactList, { type Fact } from '@/components/fact-list.vue'
const facts: Fact[] = [
{ key: 'source', label: 'Source' },
{ label: 'Payment ID', value: 'pay_8f2a4c1e', mono: true, copy: true },
{ label: 'Merchant reference', value: 'ORD-2026-0912', mono: true, copy: true },
{ label: 'Processing reference', value: 'CP-77A19', mono: true, copy: true },
{ label: 'External ID', value: null, mono: true, copy: true },
{ label: 'Description', value: 'Two tickets for the 14 September show, row F.', span: true },
]
</script>
<template>
<FactList :facts="facts">
<template #source>
<Badge color="blue">API</Badge>
</template>
</FactList>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
import { CopyButton } from './copy-button'
import { TextLink } from './text'
export type Fact = {
key?: string
label: string
value?: React.ReactNode
href?: string
mono?: boolean
muted?: boolean
copy?: boolean | string
span?: boolean
}
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}
export function FactList({
facts,
layout = 'grid',
columns = 3,
emptyLabel = 'Not set',
className,
...props
}: {
facts: Fact[]
layout?: 'grid' | 'rows'
columns?: 1 | 2 | 3 | 4
emptyLabel?: string
} & Omit<React.ComponentPropsWithoutRef<'dl'>, 'children'>) {
let rows = layout === 'rows'
return (
<dl
{...props}
className={clsx(
className,
rows ? 'text-base/6 sm:text-sm/6' : ['grid gap-x-8 gap-y-4', columnClasses[columns]]
)}
>
{facts.map((fact) => {
let empty = fact.value === undefined || fact.value === null || fact.value === ''
let copy = fact.copy === true ? (typeof fact.value === 'string' ? fact.value : undefined) : fact.copy || undefined
let text = empty ? emptyLabel : fact.value
let plain = empty || typeof fact.value === 'string' || typeof fact.value === 'number'
let content = fact.href && !empty ? <TextLink href={fact.href}>{text}</TextLink> : text
let tone =
empty || fact.muted
? 'text-zinc-500 dark:text-zinc-400'
: clsx('text-zinc-950 dark:text-white', rows && 'font-medium')
return (
<div
key={fact.key ?? fact.label}
className={clsx(
'min-w-0',
rows &&
'flex items-baseline justify-between gap-4 border-t border-zinc-950/5 py-2.5 first:border-none first:pt-0 last:pb-0 dark:border-white/5',
!rows && fact.span && (columns === 4 ? 'sm:col-span-2 lg:col-span-4' : columns === 3 ? 'sm:col-span-2 lg:col-span-3' : columns === 2 ? 'sm:col-span-2' : '')
)}
>
<dt
className={clsx(
'text-zinc-500 dark:text-zinc-400',
rows ? 'shrink-0' : 'text-sm/6 sm:text-xs/6'
)}
>
{fact.label}
</dt>
{!plain ? (
<dd className={rows ? 'flex min-w-0 flex-wrap justify-end gap-1' : 'mt-0.5'}>{fact.value}</dd>
) : copy && !empty ? (
<dd
className={clsx(
'flex items-center gap-1',
rows ? 'min-w-0' : 'text-base/6 sm:text-sm/6',
fact.mono && 'font-mono',
tone
)}
>
<span className="truncate">{content}</span>
<CopyButton text={copy} label={`Copy ${fact.label.toLowerCase()}`} />
</dd>
) : (
<dd
className={clsx(
'truncate',
!rows && 'text-base/6 sm:text-sm/6',
fact.mono && !empty && 'font-mono',
tone
)}
>
{content}
</dd>
)}
</div>
)
})}
</dl>
)
}Also copy Copy button, Text.
<script lang="ts">
export type Fact = {
key?: string
label: string
value?: string | number | null
href?: string
mono?: boolean
muted?: boolean
copy?: boolean | string
span?: boolean
}
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import CopyButton from './copy-button.vue'
import TextLink from './text-link.vue'
const props = withDefaults(
defineProps<{
facts: Fact[]
layout?: 'grid' | 'rows'
columns?: 1 | 2 | 3 | 4
emptyLabel?: string
}>(),
{ layout: 'grid', columns: 3, emptyLabel: 'Not set' }
)
const slots = useSlots()
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}
const spanClasses = {
1: '',
2: 'sm:col-span-2',
3: 'sm:col-span-2 lg:col-span-3',
4: 'sm:col-span-2 lg:col-span-4',
}
const rows = computed(() => props.layout === 'rows')
function isEmpty(fact: Fact) {
return fact.value === undefined || fact.value === null || fact.value === ''
}
function copyText(fact: Fact) {
if (fact.copy === true) return isEmpty(fact) ? undefined : String(fact.value)
return fact.copy || undefined
}
function slotName(fact: Fact) {
return fact.key && slots[fact.key] ? fact.key : undefined
}
function safeHref(fact: Fact) {
return fact.href && /^(https?:|mailto:|tel:|\/|#)/i.test(fact.href) ? fact.href : undefined
}
function tone(fact: Fact) {
return isEmpty(fact) || fact.muted
? 'text-zinc-500 dark:text-zinc-400'
: ['text-zinc-950 dark:text-white', rows.value && 'font-medium']
}
</script>
<template>
<dl :class="rows ? 'text-base/6 sm:text-sm/6' : ['grid gap-x-8 gap-y-4', columnClasses[columns]]">
<div
v-for="fact in facts"
:key="fact.key ?? fact.label"
:class="[
'min-w-0',
rows &&
'flex items-baseline justify-between gap-4 border-t border-zinc-950/5 py-2.5 first:border-none first:pt-0 last:pb-0 dark:border-white/5',
!rows && fact.span && spanClasses[columns],
]"
>
<dt :class="['text-zinc-500 dark:text-zinc-400', rows ? 'shrink-0' : 'text-sm/6 sm:text-xs/6']">
{{ fact.label }}
</dt>
<dd v-if="slotName(fact)" :class="rows ? 'flex min-w-0 flex-wrap justify-end gap-1' : 'mt-0.5'">
<slot :name="slotName(fact)!" :fact="fact" />
</dd>
<dd
v-else-if="copyText(fact) && !isEmpty(fact)"
:class="['flex items-center gap-1', rows ? 'min-w-0' : 'text-base/6 sm:text-sm/6', fact.mono && 'font-mono', tone(fact)]"
>
<span class="truncate">
<TextLink v-if="safeHref(fact)" :href="safeHref(fact)!">{{ fact.value }}</TextLink>
<template v-else>{{ fact.value }}</template>
</span>
<CopyButton :text="copyText(fact)!" :label="`Copy ${fact.label.toLowerCase()}`" />
</dd>
<dd v-else :class="['truncate', !rows && 'text-base/6 sm:text-sm/6', fact.mono && !isEmpty(fact) && 'font-mono', tone(fact)]">
<template v-if="isEmpty(fact)">{{ emptyLabel }}</template>
<TextLink v-else-if="safeHref(fact)" :href="safeHref(fact)!">{{ fact.value }}</TextLink>
<template v-else>{{ fact.value }}</template>
</dd>
</div>
</dl>
</template>Also copy Copy button, Text.
Component API
| Prop | Default | Description |
|---|---|---|
FactListrenders a <dl> element | ||
facts | - | Array of `{ key, label, value, href, mono, muted, copy, span }`. |
layout | grid | `grid` stacks the label over the value in columns. `rows` puts them face to face on divided lines. |
columns | 3 | Columns of the grid layout, from `lg` down. 1 to 4. |
emptyLabel | Not set | Shown, muted, when a fact has no value. |
Factrenders a <object> element | ||
key | - | Stable identity of the fact. Defaults to the label, set it when two labels repeat. |
label | - | The term. |
value | - | The text, or any node. Empty, it renders the `emptyLabel`. |
href | - | Links the value. |
mono | - | Identifiers and references, read character by character. |
muted | - | A value that is there but does not matter yet. |
copy | - | Adds a copy button. `true` copies a string value, a string copies that text. |
span | - | In the grid layout, takes the whole row. |
| Prop | Default | Description |
|---|---|---|
FactListrenders a <dl> element | ||
facts | - | Array of `{ key, label, value, href, mono, muted, copy, span }`. |
layout | grid | `grid` stacks the label over the value in columns. `rows` puts them face to face on divided lines. |
columns | 3 | Columns of the grid layout, from `lg` down. 1 to 4. |
emptyLabel | Not set | Shown, muted, when a fact has no value. |
#[key] | - | Scoped slot replacing the value of the fact with that `key`. Receives `{ fact }`. |
Factrenders a <object> element | ||
key | - | Stable identity of the fact, the label by default. Also names the slot that renders the value, when it is not plain text. |
label | - | The term. |
value | - | The text. Empty, it renders the `emptyLabel`. |
href | - | Links the value. Only `http:`, `https:`, `mailto:`, `tel:`, `/` and `#` URLs make a link; any other value leaves the text alone. |
mono | - | Identifiers and references, read character by character. |
muted | - | A value that is there but does not matter yet. |
copy | - | Adds a copy button. `true` copies the value, a string copies that text. |
span | - | In the grid layout, takes the whole row. |
Examples
Basic example
Each fact has a label and a value. Identifiers take mono and copy, a long text takes span to use the whole row, and any node works as a value:
- Source
- API
- Payment ID
- pay_8f2a4c1e
- Merchant reference
- ORD-2026-0912
- Processing reference
- CP-77A19
- External ID
- Not set
- Description
- Two tickets for the 14 September show, row F.
import { FactList, type Fact } from '@/components/fact-list'
import { Badge } from '@/components/badge'
const facts: Fact[] = [
{ label: 'Source', value: <Badge color="blue">API</Badge> },
{ label: 'Payment ID', value: 'pay_8f2a4c1e', mono: true, copy: true },
{ label: 'Merchant reference', value: 'ORD-2026-0912', mono: true, copy: true },
{ label: 'Processing reference', value: 'CP-77A19', mono: true, copy: true },
{ label: 'External ID', value: null, mono: true, copy: true },
{ label: 'Description', value: 'Two tickets for the 14 September show, row F.', span: true },
]
export default function Example() {
return <FactList facts={facts} />
}<script setup lang="ts">
import Badge from '@/components/badge.vue'
import FactList, { type Fact } from '@/components/fact-list.vue'
const facts: Fact[] = [
{ key: 'source', label: 'Source' },
{ label: 'Payment ID', value: 'pay_8f2a4c1e', mono: true, copy: true },
{ label: 'Merchant reference', value: 'ORD-2026-0912', mono: true, copy: true },
{ label: 'Processing reference', value: 'CP-77A19', mono: true, copy: true },
{ label: 'External ID', value: null, mono: true, copy: true },
{ label: 'Description', value: 'Two tickets for the 14 September show, row F.', span: true },
]
</script>
<template>
<FactList :facts="facts">
<template #source>
<Badge color="blue">API</Badge>
</template>
</FactList>
</template>Rows
In a narrow column, layout="rows" puts the label and the value face to face on divided lines:
- Currency
- XOF
- Created
- Mar 3, 2026
- Last updated
- Sep 8, 2026
- Tags
- wholesalepriority
- Languages
- French, English
const details: Fact[] = [
{ label: 'Currency', value: 'XOF' },
{ label: 'Created', value: 'Mar 3, 2026' },
{ label: 'Last updated', value: 'Sep 8, 2026' },
{ label: 'Tags', value: <><Badge color="zinc">wholesale</Badge><Badge color="zinc">priority</Badge></> },
{ label: 'Languages', value: 'French, English' },
]
<FactList facts={details} layout="rows" className="max-w-md" /><FactList :facts="details" layout="rows" class="max-w-md">
<template #tags>
<Badge color="zinc">wholesale</Badge>
<Badge color="zinc">priority</Badge>
</template>
</FactList>Columns
Set columns to fit the space, from one to four. The grid steps down on smaller screens:
- Payment ID
- pay_8f2a4c1e
- Merchant reference
- ORD-2026-0912
- Processing reference
- CP-77A19
- External ID
- Not set
<FactList facts={facts} columns={2} /><FactList :facts="facts" :columns="2" />Links and muted values
Give a fact an href to link its value. Mark a value muted when it is there but does not count yet:
- Customer
- Awa Diallo
- Source payment
- XOF 12,500
- Created
- Sep 10, 2026, 9:14 AM
- Completed
- Still open
const refund: Fact[] = [
{ label: 'Customer', value: 'Awa Diallo', href: '/customers/cus_91' },
{ label: 'Source payment', value: 'XOF 12,500', href: '/payments/pay_8f2a' },
{ label: 'Created', value: 'Sep 10, 2026, 9:14 AM' },
{ label: 'Completed', value: 'Still open', muted: true },
]
<FactList facts={refund} columns={2} />const refund: Fact[] = [
{ label: 'Customer', value: 'Awa Diallo', href: '/customers/cus_91' },
{ label: 'Source payment', value: 'XOF 12,500', href: '/payments/pay_8f2a' },
{ label: 'Created', value: 'Sep 10, 2026, 9:14 AM' },
{ label: 'Completed', value: 'Still open', muted: true },
]
<FactList :facts="refund" :columns="2" />Empty values
A fact without a value shows the emptyLabel, muted, and never a copy button:
- Phone
- Not provided
- awa@example.com
<FactList facts={[{ label: 'Phone', value: null }, { label: 'Email', value: 'awa@example.com' }]} emptyLabel="Not provided" /><FactList :facts="[{ label: 'Phone', value: null }, { label: 'Email', value: 'awa@example.com' }]" empty-label="Not provided" />