Category Table
A small table for the part of a page that says where something goes: storage by category, spend by team, traffic by source. Each row has a duotone icon, a name, a short note in brackets, and one or two figures on the right.
Hover a row, or tab to it, and its icon plays. Click the row and it opens to
show a sentence of detail and a meter for its share of the total. One row is
open at a time, and Esc closes it. Use Full screen to see the table at
page width.
Four icons, one movement each
Each icon has two tones of one hue and is drawn on a 24px grid. Each has a
rest pose and an active pose, and one idea for getting between them.
- Database. The lid lifts and the status light slides across and blinks twice.
- Images. The back picture fans out, the front one tips the other way, and the sun and hill both come up.
- Documents. The page tilts and its three lines are written again, one after the other. The short last line finishes long.
- Backups. The cloud floats up. The gap around it is a mask, not a stroke in the card colour, so it follows the cloud and works on any surface.
Going to active is a 500ms spring with some bounce. Going back is 300ms with
none, so the icon never wobbles twice. The pose holds for as long as the row
is hovered, focused, or open, so nothing loops. The colour is in the icons and
the meter only. The rest of the table stays neutral.
The row drives the icon
The icons take no props inside the table. Each row is a motion.li that
switches between the rest and active variants, and the icon picks that up
through context. This means the whole row is the hover target, not the 24px
icon, and any icon you write with the same two variant names will work in the
icon slot.
Outside a table, pass active to an icon yourself, or put it inside any
motion element that sets those variants, such as a button with
whileHover="active".
Opening a row
- The panel grows from zero height on a 400ms spring with no bounce. The caret turns over in the same time.
- The meter fills from the left 100ms later, over 700ms, in the row's accent colour. The percentage beside it uses tabular figures.
- The panel text starts under the label, not under the icon, so the icons stay a clean column.
- An open row keeps its icon in the
activepose. Touch screens have no hover, so this is how they see the movement.
Narrow containers
The table responds to its own width, not the viewport. Below @lg (512px) the
note drops under the name and loses its brackets, and the files column is
hidden. Long names and notes are cut with an ellipsis. They never wrap or push
the figures out.
Accessibility
- Each row is a real
buttonwitharia-expanded, and its panel is a labelled region. - The column headings are visual only. Each figure carries its heading as hidden text, so a row reads as "Database, PostgreSQL & Redis cluster, Files: 2 clusters, Size: 48.2 GB".
- The meter is a
role="meter"with its value. - With reduced motion the icons do not move, and the panel and meter appear at once.
Usage
<CategoryTable
heading="Category"
defaultOpen={0}
items={[
{
label: "Database",
description: "PostgreSQL & Redis cluster",
icon: <DatabaseIcon />,
files: "2 clusters",
size: "48.2 GB",
share: 0.38,
detail: "Rows, indexes, and the write-ahead log.",
accent: "bg-blue-600 dark:bg-blue-500",
},
]}
/>
"use client";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";
import {
BackupsIcon,
DatabaseIcon,
DocumentsIcon,
ImagesIcon,
} from "@/components/blocks/features/category-icons";
import { cn } from "@/lib/utils";
export type CategoryTableItem = {
label: string;
/** Short note shown beside the label, in brackets on wide containers. */
description?: string;
/** Any node. Motion children with `rest` and `active` variants play while
* the row is hovered, focused, or open. */
icon?: React.ReactNode;
files?: string;
size?: string;
/** 0–1. Drawn as a meter when the row is open. */
share?: number;
/** Shown when the row is open. */
detail?: React.ReactNode;
/** Classes for the meter fill. Defaults to the foreground colour. */
accent?: string;
};
export type CategoryTableProps = {
items?: CategoryTableItem[];
heading?: string;
filesHeading?: string;
sizeHeading?: string;
/** Text after the percentage on the meter. */
shareLabel?: string;
/** Index of the row that starts open. */
defaultOpen?: number | null;
className?: string;
};
const DEFAULT_ITEMS: CategoryTableItem[] = [
{
label: "Database",
description: "PostgreSQL & Redis cluster",
icon: <DatabaseIcon />,
files: "2 clusters",
size: "48.2 GB",
share: 0.38,
detail: "Rows, indexes, and the write-ahead log, across one primary and two replicas.",
accent: "bg-blue-600 dark:bg-blue-500",
},
{
label: "Images & Media",
description: "User uploads & assets",
icon: <ImagesIcon />,
files: "18,204",
size: "36.5 GB",
share: 0.29,
detail: "Originals plus the resized versions made for each screen size.",
accent: "bg-emerald-500",
},
{
label: "Documents",
description: "Generated PDFs & reports",
icon: <DocumentsIcon />,
files: "6,930",
size: "12.8 GB",
share: 0.1,
detail: "Invoices, exports, and monthly reports. Anything older than a year is archived.",
accent: "bg-orange-500",
},
{
label: "Backups",
description: "Automated daily snapshots",
icon: <BackupsIcon />,
files: "30",
size: "22.4 GB",
share: 0.18,
detail: "One snapshot a night, kept for 30 days and stored in a second region.",
accent: "bg-pink-500",
},
];
function Row({
item,
open,
onToggle,
filesHeading,
sizeHeading,
shareLabel,
}: {
item: CategoryTableItem;
open: boolean;
onToggle: () => void;
filesHeading: string;
sizeHeading: string;
shareLabel: string;
}) {
const id = useId();
const reduceMotion = useReducedMotion();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const expandable = item.detail != null || item.share != null;
const percent = Math.round(Math.min(Math.max(item.share ?? 0, 0), 1) * 100);
const transition = reduceMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.4, bounce: 0 } as const);
return (
// The variant set here reaches the icon through context, so the icon
// needs no props of its own. Open rows hold the pose, which is also how
// touch screens get to see it.
<motion.li
initial={false}
animate={hovered || focused || open ? "active" : "rest"}
className="border-b border-border last:border-b-0"
>
<button
type="button"
id={`${id}-row`}
aria-expanded={expandable ? open : undefined}
aria-controls={expandable ? `${id}-panel` : undefined}
onClick={expandable ? onToggle : undefined}
onKeyDown={(e) => {
if (e.key === "Escape" && open) onToggle();
}}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={() => setHovered(false)}
onFocus={(e) => setFocused(e.currentTarget.matches(":focus-visible"))}
onBlur={() => setFocused(false)}
className={cn(
"flex min-h-13 w-full items-center gap-4 px-5 py-3 text-left text-sm outline-none transition-colors duration-150 hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-[1.5px] focus-visible:ring-ring/50 focus-visible:ring-inset active:bg-muted",
expandable && "cursor-pointer",
)}
>
{item.icon}
<span className="flex min-w-0 flex-1 flex-col gap-x-2.5 @lg:flex-row @lg:items-baseline">
<span className="truncate font-medium text-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground @lg:before:content-['('] @lg:after:content-[')']">
{item.description}
</span>
)}
</span>
{item.files && (
<span className="hidden w-20 shrink-0 text-right text-muted-foreground tabular-nums @lg:block">
<span className="sr-only">{filesHeading}: </span>
{item.files}
</span>
)}
{item.size && (
<span className="w-16 shrink-0 text-right text-foreground tabular-nums">
<span className="sr-only">{sizeHeading}: </span>
{item.size}
</span>
)}
<CaretDownIcon
aria-hidden
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-300 ease-snappy motion-reduce:transition-none",
open && "rotate-180",
!expandable && "invisible",
)}
/>
</button>
<AnimatePresence initial={false}>
{open && expandable && (
<motion.div
id={`${id}-panel`}
role="region"
aria-labelledby={`${id}-row`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition}
className="overflow-hidden"
>
{/* Left padding lines the panel up with the label, past the icon. */}
<div className="flex flex-col gap-3 pt-1 pr-5 pb-4 pl-15 text-sm">
{item.detail != null && (
<p className="text-pretty text-muted-foreground">{item.detail}</p>
)}
{item.share != null && (
<div className="flex items-center gap-3">
<div
role="meter"
aria-label={`${item.label}, ${shareLabel}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
>
<motion.span
initial={{ width: 0 }}
animate={{ width: `${percent}%` }}
transition={reduceMotion ? transition : { ...transition, duration: 0.7, delay: 0.1 }}
className={cn("block h-full rounded-full", item.accent ?? "bg-foreground")}
/>
</div>
<span className="shrink-0 text-muted-foreground tabular-nums">
<span className="text-foreground">{percent}%</span> {shareLabel}
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.li>
);
}
export function CategoryTable({
items = DEFAULT_ITEMS,
heading = "Category",
filesHeading = "Files",
sizeHeading = "Size",
shareLabel = "of storage",
defaultOpen = null,
className,
}: CategoryTableProps) {
const [open, setOpen] = useState<number | null>(defaultOpen);
return (
<div className={cn("@container w-full", className)}>
{/* Turns the icon movement off for people who ask for reduced motion. */}
<MotionConfig reducedMotion="user">
<div className="overflow-hidden rounded-2xl bg-card shadow-(--custom-shadow)">
<div
aria-hidden
className="flex items-center gap-4 border-b border-border bg-muted/40 px-5 py-3.5 text-sm text-muted-foreground"
>
<span className="flex-1">{heading}</span>
<span className="hidden w-20 text-right @lg:block">{filesHeading}</span>
<span className="w-16 text-right">{sizeHeading}</span>
<span className="size-3.5" />
</div>
<ul aria-label={heading}>
{items.map((item, i) => (
<Row
key={item.label}
item={item}
open={open === i}
onToggle={() => setOpen(open === i ? null : i)}
filesHeading={filesHeading}
sizeHeading={sizeHeading}
shareLabel={shareLabel}
/>
))}
</ul>
</div>
</MotionConfig>
</div>
);
}"use client";
import { motion, type Variants } from "motion/react";
import { useId } from "react";
import { cn } from "@/lib/utils";
export type CategoryIconProps = {
className?: string;
/** Plays the icon when true. Leave unset inside a motion parent that
* switches between the `rest` and `active` variants, and it follows that. */
active?: boolean;
};
// Arriving has a little bounce; going back has none, so it never wobbles twice.
const settle = { type: "spring", duration: 0.5, bounce: 0.45 } as const;
const back = { type: "spring", duration: 0.3, bounce: 0 } as const;
type Pose = Record<string, number>;
const pose = (rest: Pose, active: Pose, delay = 0): Variants => ({
rest: { ...rest, transition: back },
active: { ...active, transition: { ...settle, delay } },
});
function Shell({
active,
className,
children,
}: CategoryIconProps & { children: React.ReactNode }) {
return (
<motion.svg
viewBox="0 0 24 24"
aria-hidden
initial={false}
animate={active === undefined ? undefined : active ? "active" : "rest"}
className={cn("size-6 shrink-0 overflow-visible", className)}
>
{children}
</motion.svg>
);
}
/** A drive. The lid lifts and the status light slides across and blinks. */
export function DatabaseIcon(props: CategoryIconProps) {
return (
<Shell {...props}>
<motion.path
d="M6.4 4h11.2a2 2 0 0 1 1.9 1.37L22 13H2l2.5-7.63A2 2 0 0 1 6.4 4Z"
className="fill-blue-600 dark:fill-blue-500"
variants={pose({ y: 0 }, { y: -1.5 })}
/>
<rect x="2" y="11" width="20" height="9" rx="3" className="fill-blue-300 dark:fill-blue-900" />
<motion.rect
x="5.5"
y="14.6"
width="5"
height="1.8"
rx="0.9"
className="fill-blue-600 dark:fill-blue-400"
variants={{
rest: { x: 0, opacity: 1, transition: back },
active: {
x: 8,
opacity: [1, 0.25, 1, 0.25, 1],
transition: { x: settle, opacity: { duration: 0.6, delay: 0.15 } },
},
}}
/>
</Shell>
);
}
/** Two stacked pictures. The back one fans out and the sun comes up. */
export function ImagesIcon(props: CategoryIconProps) {
const clip = useId();
return (
<Shell {...props}>
<motion.rect
x="2"
y="2.5"
width="14"
height="14"
rx="3.5"
className="fill-emerald-400 dark:fill-emerald-500"
variants={pose({ rotate: -8, x: 0, y: 0 }, { rotate: -20, x: -1.5, y: -0.5 })}
/>
<motion.g variants={pose({ rotate: 0, x: 0, y: 0 }, { rotate: 5, x: 0.5, y: 0.5 })}>
<clipPath id={clip}>
<rect x="8" y="8" width="14" height="14" rx="3.5" />
</clipPath>
<rect x="8" y="8" width="14" height="14" rx="3.5" className="fill-emerald-100 dark:fill-emerald-900" />
<g clipPath={`url(#${clip})`}>
<motion.path
d="M8 23 15 15.9a1.5 1.5 0 0 1 2.1 0L23 21.8V23Z"
className="fill-emerald-400 dark:fill-emerald-500"
variants={pose({ y: 0 }, { y: -1.5 }, 0.05)}
/>
</g>
<motion.circle
cx="12.6"
cy="12.6"
r="1.6"
className="fill-emerald-400 dark:fill-emerald-500"
variants={pose({ y: 0, scale: 1 }, { y: -1, scale: 1.25 }, 0.1)}
/>
</motion.g>
</Shell>
);
}
const DOCUMENT_LINES = [
{ y: 6.6, rest: 1 },
{ y: 10.1, rest: 1 },
{ y: 13.6, rest: 0.55 },
];
/** A page. It tilts and its lines are written again, one after the other. */
export function DocumentsIcon(props: CategoryIconProps) {
return (
<Shell {...props}>
<motion.g variants={pose({ rotate: 0, y: 0 }, { rotate: -6, y: -0.5 })}>
<rect x="4" y="2" width="16" height="20" rx="4" className="fill-orange-300 dark:fill-orange-900" />
{DOCUMENT_LINES.map((line, i) => (
<motion.rect
key={line.y}
x="7.5"
y={line.y}
width="9"
height="1.8"
rx="0.9"
className="fill-orange-500 dark:fill-orange-400"
style={{ originX: 0 }}
variants={{
rest: { scaleX: line.rest, transition: back },
active: {
scaleX: [line.rest, 0.2, 1],
transition: { duration: 0.45, ease: "easeOut", delay: i * 0.08 },
},
}}
/>
))}
</motion.g>
</Shell>
);
}
const CLOUD = "M14.5 22a2.75 2.75 0 0 1-.3-5.48 3.6 3.6 0 0 1 6.9 1.03A2.25 2.25 0 0 1 20.75 22Z";
const cloudPose = pose({ y: 0, scale: 1 }, { y: -2.5, scale: 1.1 });
/** A folder with a cloud. The cloud floats up, and its cut-out follows it. */
export function BackupsIcon(props: CategoryIconProps) {
const mask = useId();
return (
<Shell {...props}>
{/* The gap around the cloud is a real hole, so it works on any surface. */}
<mask id={mask} maskUnits="userSpaceOnUse" x="0" y="0" width="26" height="26">
<rect width="26" height="26" fill="white" />
<motion.path d={CLOUD} fill="black" stroke="black" strokeWidth="2.6" variants={cloudPose} />
</mask>
<g mask={`url(#${mask})`}>
<path
d="M2 6.5A2.5 2.5 0 0 1 4.5 4h3.9a2 2 0 0 1 1.5.7l1.6 1.8h8A2.5 2.5 0 0 1 22 9v4H2Z"
className="fill-pink-500"
/>
<rect x="2" y="8.5" width="20" height="11.5" rx="2.5" className="fill-pink-300 dark:fill-pink-900" />
</g>
<motion.path d={CLOUD} className="fill-pink-500" variants={cloudPose} />
</Shell>
);
}