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.
npx shadcn@latest add @paragon/description-listAlso installs: copy-button, tooltip
"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>
);
}