Skip to content
  1. Records
  2. Fact list

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

PropDefaultDescription
FactListrenders a <dl> element
facts-Array of `{ key, label, value, href, mono, muted, copy, span }`.
layoutgrid`grid` stacks the label over the value in columns. `rows` puts them face to face on divided lines.
columns3Columns of the grid layout, from `lg` down. 1 to 4.
emptyLabelNot setShown, 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.
PropDefaultDescription
FactListrenders a <dl> element
facts-Array of `{ key, label, value, href, mono, muted, copy, span }`.
layoutgrid`grid` stacks the label over the value in columns. `rows` puts them face to face on divided lines.
columns3Columns of the grid layout, from `lg` down. 1 to 4.
emptyLabelNot setShown, 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" />

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
Email
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" />