Description List
Data Display

Description List

A detail-pane definition list with container-query responsive stacking, hover-revealed inline copy and edit affordances, monospace and truncating value modes, and a loading skeleton.

Install

npx shadcn@latest add @paragon/description-list

Also installs: copy-button, tooltip

description-list.tsx

"use client";

import * as React from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import { CopyButton } from "@/registry/paragon/ui/copy-button";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

type Density = "comfortable" | "compact";

const DensityContext = React.createContext<Density>("comfortable");

export interface DescriptionListProps extends React.ComponentProps<"dl"> {
  density?: Density;
  /** Skeleton rows while the record loads. */
  loading?: boolean;
  /** How many skeleton rows to draw. */
  loadingRows?: number;
}

/**
 * A settings/detail-pane definition list. Terms and details sit in a
 * two-column grid that stacks below its container's md breakpoint (container
 * query, so it responds to the pane — not the viewport). Values can expose
 * copy and edit affordances that surface on row hover or keyboard focus and
 * stay visible on coarse pointers.
 */
export function DescriptionList({
  density = "comfortable",
  loading = false,
  loadingRows = 4,
  className,
  children,
  ...props
}: DescriptionListProps) {
  return (
    <DensityContext.Provider value={density}>
      <TooltipProvider>
        <dl
          data-slot="description-list"
          aria-busy={loading || undefined}
          className={cn("@container w-full divide-y divide-border", className)}
          {...props}
        >
          {loading
            ? Array.from({ length: loadingRows }, (_, i) => (
                <div
                  key={i}
                  aria-hidden
                  className={cn(
                    "grid grid-cols-1 gap-1.5 @md:grid-cols-[minmax(7rem,12rem)_1fr] @md:gap-4",
                    density === "compact" ? "py-2" : "py-3",
                  )}
                >
                  <div
                    className="h-2.5 w-20 rounded-sm bg-muted animate-pulse motion-reduce:animate-none"
                    style={{ animationDelay: `${i * 80}ms` }}
                  />
                  <div
                    className="h-2.5 rounded-sm bg-muted animate-pulse motion-reduce:animate-none"
                    style={{ width: `${[68, 44, 82, 56][i % 4]}%`, animationDelay: `${i * 80}ms` }}
                  />
                </div>
              ))
            : children}
        </dl>
      </TooltipProvider>
    </DensityContext.Provider>
  );
}

export interface DescriptionRowProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  term: React.ReactNode;
  children: React.ReactNode;
  /** Shows a copy button on hover that writes this string. */
  copyValue?: string;
  /** Shows an edit pencil on hover. */
  onEdit?: () => void;
  editLabel?: string;
  /** Monospace value — ids, endpoints, keys. */
  mono?: boolean;
  /** Single-line value with ellipsis and a title on overflow. */
  truncate?: boolean;
  /** Disables the copy icon-swap motion. */
  static?: boolean;
}

export function DescriptionRow({
  term,
  children,
  copyValue,
  onEdit,
  editLabel = "Edit",
  mono = false,
  truncate = false,
  static: isStatic = false,
  className,
  ...props
}: DescriptionRowProps) {
  const density = React.useContext(DensityContext);
  const hasActions = copyValue !== undefined || onEdit !== undefined;

  return (
    <div
      data-slot="description-row"
      className={cn(
        "group/row grid grid-cols-1 gap-1 @md:grid-cols-[minmax(7rem,12rem)_1fr] @md:gap-4",
        density === "compact" ? "py-2" : "py-3",
        className,
      )}
      {...props}
    >
      <dt className="flex items-center text-[13px] text-muted-foreground">
        {term}
      </dt>
      <dd className="flex min-w-0 items-center gap-2">
        <span
          className={cn(
            "min-w-0 text-sm text-foreground",
            mono && "font-mono text-[13px]",
            truncate && "truncate",
          )}
          title={
            truncate && typeof children === "string" ? children : undefined
          }
        >
          {children}
        </span>
        {hasActions && (
          <span
            className={cn(
              "ml-auto flex shrink-0 items-center gap-0.5",
              "opacity-0 transition-opacity duration-(--duration-fast)",
              "group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100",
            )}
          >
            {copyValue !== undefined && (
              <Tooltip>
                <TooltipTrigger asChild>
                  <CopyButton
                    value={copyValue}
                    static={isStatic}
                    className="size-6 after:size-8"
                  />
                </TooltipTrigger>
                <TooltipContent>Copy</TooltipContent>
              </Tooltip>
            )}
            {onEdit && (
              <Tooltip>
                <TooltipTrigger asChild>
                  <button
                    type="button"
                    aria-label={editLabel}
                    onClick={onEdit}
                    className={cn(
                      "relative flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground",
                      "transition-colors duration-150 ease-out hover:text-foreground",
                      "after:absolute after:top-1/2 after:left-1/2 after:size-8 after:-translate-1/2",
                      !isStatic && "pressable",
                    )}
                  >
                    <Pencil className="size-3.5" />
                  </button>
                </TooltipTrigger>
                <TooltipContent>{editLabel}</TooltipContent>
              </Tooltip>
            )}
          </span>
        )}
      </dd>
    </div>
  );
}