Selectable cards whose corner check pops in, with a select-all that counts.
01Preview
Events to send
POST https://api.northwind.dev/hooks/ship
DeploymentsCreated, succeeded, failed or canceled
Pull requestsOpened, merged or closed on any repository
ReleasesBetaPublished tags with their changelog
BillingOnly workspace owners can send invoices
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react @number-flow/react03Usage
import { CheckboxCard, CheckboxCardGroup } from "@/components/ui/checkbox-card";
<CheckboxCardGroup aria-label="Webhook events" allValues={["deploys", "issues"]} selectAll>
<CheckboxCard value="deploys" title="Deployments" description="Created, succeeded or failed" icon={<Bolt />} />
<CheckboxCard value="issues" title="Issues" description="Created, assigned and resolved" icon={<Alert />} />
</CheckboxCardGroup>04Source
"use client";
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group";
import NumberFlow from "@number-flow/react";
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
import { Checkbox } from "@/components/ui/checkbox";
export type CheckboxCardGroupProps = Omit<BaseCheckboxGroup.Props, "className" | "value" | "defaultValue" | "onValueChange"> & {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Adds a select-all row above the cards. Needs `allValues`. */
selectAll?: boolean;
selectAllLabel?: React.ReactNode;
/** Cards per row once the group is wider than 384px. Always one column below that. */
columns?: 1 | 2 | 3;
className?: string;
};
export function CheckboxCardGroup({
value: valueProp,
defaultValue = [],
onValueChange,
selectAll = false,
selectAllLabel = "Select all",
allValues,
columns = 2,
className,
children,
...rest
}: CheckboxCardGroupProps) {
// Held here so the select-all parent always has a controlled group to read.
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
const total = allValues?.length ?? 0;
const count = allValues ? value.filter((v) => allValues.includes(v)).length : value.length;
return (
<BaseCheckboxGroup
value={value}
onValueChange={(next) => setValue(next)}
allValues={allValues}
className={cn("@container flex w-full flex-col gap-2.5", className)}
{...rest}
>
{selectAll && allValues && (
<div className="flex min-h-7 items-center justify-between gap-3">
<Checkbox parent size="sm" label={selectAllLabel} />
{/* Fixed line box: the rolling digits sit inline-block and would nudge the baseline. */}
<span className="flex h-5 shrink-0 items-center text-[12px] tabular text-fg-3">
<span>
{count === 0 ? (
"None selected"
) : (
<>
<NumberFlow value={count} className="text-fg-2" /> of {total} selected
</>
)}
</span>
</span>
</div>
)}
<div
className={cn(
"grid gap-2",
columns === 2 && "@sm:grid-cols-2",
columns === 3 && "@sm:grid-cols-2 @xl:grid-cols-3",
)}
>
{children}
</div>
</BaseCheckboxGroup>
);
}
export type CheckboxCardProps = Omit<BaseCheckbox.Root.Props, "className" | "children" | "title" | "value" | "parent"> & {
/** Identifies the card inside the group. */
value: string;
title: React.ReactNode;
description?: React.ReactNode;
/** A 14px icon, drawn in a tile at the start of the card. */
icon?: React.ReactNode;
/** Small trailing text beside the title, like a plan name or "Beta". */
badge?: React.ReactNode;
className?: string;
};
export function CheckboxCard({ value, title, description, icon, badge, className, ...rest }: CheckboxCardProps) {
const titleId = useId();
const descriptionId = useId();
return (
<BaseCheckbox.Root
value={value}
render={<div />}
aria-labelledby={titleId}
aria-describedby={description != null ? descriptionId : undefined}
className={(state) =>
cn(
"group/card relative flex min-w-0 select-none items-start gap-3 rounded-xl border p-3 pr-9 text-left outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out-expo",
state.checked
? "border-fg-3 bg-hover shadow-[inset_0_0_0_0.5px_var(--fg-3)]"
: "border-line-2 bg-raised",
state.disabled
? "cursor-not-allowed opacity-50"
: !state.readOnly && cn("motion-safe:active:scale-[0.985] active:duration-100", !state.checked && "hover:border-fg-4"),
className,
)
}
{...rest}
>
{icon != null && (
<span
aria-hidden
className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-frame text-fg-2 transition-colors duration-150 group-data-checked/card:text-fg [&_svg]:size-3.5"
>
{icon}
</span>
)}
<span className="flex min-w-0 flex-col gap-0.5 pt-px">
<span className="flex min-w-0 items-center gap-1.5">
<span id={titleId} className="truncate text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg">
{title}
</span>
{badge != null && (
<span className="shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-2">{badge}</span>
)}
</span>
{description != null && (
<span id={descriptionId} className="line-clamp-2 text-[12.5px] leading-[18px] text-fg-3">
{description}
</span>
)}
</span>
<BaseCheckbox.Indicator
keepMounted
className="pointer-events-none absolute right-3 top-3"
render={(props, state) => (
<span {...props}>
<CornerCheck checked={state.checked} idle={!state.disabled && !state.readOnly} />
</span>
)}
/>
</BaseCheckbox.Root>
);
}
/** An empty ring that fills and pops, then draws its tick. */
function CornerCheck({ checked, idle }: { checked: boolean; idle: boolean }) {
const reduce = useReducedMotion();
return (
<span className="relative grid size-4 place-items-center rounded-full">
<span
className={cn(
"absolute inset-0 rounded-full border transition-[border-color,opacity] duration-150",
checked ? "border-transparent" : cn("border-fg-4", idle && "group-hover/card:border-fg-3"),
)}
/>
<motion.span
className="absolute inset-0 grid place-items-center rounded-full bg-fg text-frame"
initial={false}
animate={checked ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.5 }}
// Reduced motion keeps the fade and snaps the scale. The targets stay the same so SSR matches.
transition={reduce ? { opacity: { duration: 0.12 }, scale: { duration: 0 } } : checked ? spring.pop : { duration: 0.12, ease: ease.in }}
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden focusable="false">
<motion.path
d="M3.5 8.5 L6.75 11.5 L12.5 4.75"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
transition={
reduce
? { duration: 0 }
: checked
? { pathLength: { duration: 0.22, ease: ease.out, delay: 0.06 }, opacity: { duration: 0.01, delay: 0.06 } }
: { duration: 0.08 }
}
/>
</svg>
</motion.span>
</span>
);
}05Props
CheckboxCardGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string[] | — | Controlled list of selected card values. |
| defaultValue | string[] | [] | Uncontrolled initial selection. |
| onValueChange | (value: string[]) => void | — | Called with the new selection. |
| allValues | string[] | — | Every selectable value. Needed for selectAll and the count. |
| selectAll | boolean | false | Adds a select-all row that goes mixed, with a rolling “2 of 5 selected” count. |
| selectAllLabel | ReactNode | "Select all" | Label for the select-all checkbox. |
| columns | 1 | 2 | 3 | 2 | Cards per row once the group itself is wider than 384px; one column below that. |
| disabled | boolean | false | Disables every card. |
CheckboxCard
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Identifies the card in the group. |
| title* | ReactNode | — | The card's name and its accessible label. Truncates on one line. |
| description | ReactNode | — | Up to two lines, then clamps. Linked as the description. |
| icon | ReactNode | — | Icon shown in a 32px tile at the start. |
| badge | ReactNode | — | Small pill beside the title, like “Beta”. |
| disabled | boolean | false | Dims the card and removes it from the tab order. |
06Notes
Behavior
- The select-all only counts cards listed in allValues, so a disabled card left out of it never blocks “all”.
- Columns follow the group's own width through a container query, so the same group works in a sidebar and a page.
- Titles truncate and descriptions clamp to two lines; the corner check has reserved padding so text never runs under it.
- The count reads “None selected” at zero rather than “0 of 5”.
Motion
- The corner check fills and scales from 0.5 on the pop spring (600/30), then draws its tick in 220ms after a 60ms beat.
- Deselecting fades and shrinks it in 120ms; the border steps from line-2 to fg-3 in 150ms.
- The card presses to 0.985, less than a button, because it is large. The count rolls with NumberFlow. Reduced motion keeps a 120ms fade and drops scale, press and draw.
Accessibility
- Each card is a role=checkbox labeled by its title and described by its description; Space toggles.
- Every card is its own tab stop, as checkboxes are; the select-all is a real mixed-state checkbox, and the count beside it is read as plain text.
- Label the group with aria-label or aria-labelledby pointing at the visible heading.