A required-consent checklist that draws each check and gates a primary action until every required item is acknowledged, with a live remaining count.
npx shadcn@latest add @paragon/consent-checklistAlso installs: button
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Button } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";
export interface ConsentItem {
id: string;
label: string;
/** Sub-text under the label. */
description?: string;
/** Required consents gate the primary action. Defaults to true. */
required?: boolean;
}
export interface ConsentChecklistProps
extends Omit<React.ComponentProps<"div">, "onSubmit"> {
items: ConsentItem[];
/** Primary action label. */
actionLabel?: string;
/** Fires with the set of checked ids when the gated action is taken. */
onConsent?: (checkedIds: string[]) => void;
static?: boolean;
}
/**
* A consent checklist that gates a primary action until every required item
* is acknowledged. Toggling an item draws its check (pathLength +
* stroke-dashoffset transition, retargetable). A live count shows remaining
* required consents; when the last one lands, the action enables and its label
* blur-swaps in. Reduced motion keeps the color settle and drops the draw.
*/
export function ConsentChecklist({
items,
actionLabel = "Continue",
onConsent,
static: isStatic = false,
className,
...props
}: ConsentChecklistProps) {
const [checked, setChecked] = React.useState<Set<string>>(() => new Set());
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const requiredIds = items.filter((i) => i.required !== false).map((i) => i.id);
const remaining = requiredIds.filter((id) => !checked.has(id)).length;
const ready = remaining === 0;
const toggle = (id: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div
data-slot="consent-checklist"
className={cn(
"flex w-full max-w-md flex-col gap-1 rounded-xl bg-card p-3 shadow-border",
className,
)}
{...props}
>
<ul className="flex flex-col gap-0.5">
{items.map((item) => {
const isChecked = checked.has(item.id);
const isRequired = item.required !== false;
return (
<li key={item.id} className="flex">
<button
type="button"
role="checkbox"
aria-checked={isChecked}
onClick={() => toggle(item.id)}
className="group flex w-full items-start gap-3 rounded-lg px-2 py-2.5 text-left transition-colors duration-(--duration-fast) hover:bg-accent/60"
>
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-[6px] border",
isChecked
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-transparent text-transparent",
!isStatic &&
"transition-[background-color,border-color] duration-(--duration-quick) ease-out",
)}
>
<svg viewBox="0 0 14 14" fill="none" className="size-3.5">
<path
d="M3 7.4 5.6 10 11 4"
pathLength={1}
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={1}
style={{
strokeDashoffset: isChecked ? 0 : 1,
transition: isStatic
? undefined
: isChecked
? "stroke-dashoffset 200ms var(--ease-out) 60ms"
: "stroke-dashoffset 100ms var(--ease-exit)",
}}
/>
</svg>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm text-foreground">
{item.label}
{!isRequired && (
<span className="text-[11px] font-normal text-muted-foreground">
(optional)
</span>
)}
</span>
{item.description && (
<span className="text-xs text-muted-foreground">
{item.description}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
<div className="mt-1 flex items-center justify-between gap-3 px-2 pt-1">
<span
aria-live="polite"
className="text-xs text-muted-foreground tabular-nums"
>
{ready
? "All required consents acknowledged"
: `${remaining} required consent${remaining === 1 ? "" : "s"} remaining`}
</span>
<Button
size="sm"
disabled={!ready}
onClick={() => onConsent?.([...checked])}
>
{animate ? (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={ready ? "ready" : "waiting"}
initial={{ opacity: 0, y: 6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -6, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{actionLabel}
</motion.span>
</AnimatePresence>
) : (
actionLabel
)}
</Button>
</div>
</div>
);
}