A SOC 2 / ISO control checklist with per-control evidence status, met/in-progress/gap chips, and an overall completion bar that fills on view.
npx shadcn@latest add @paragon/compliance-checklist"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check, Clock, FileText, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type ControlStatus = "met" | "in-progress" | "gap";
export interface ComplianceControl {
id: string;
label: string;
status: ControlStatus;
/** Evidence artifact name, e.g. "AWS Config export". */
evidence?: string;
}
export interface ComplianceChecklistProps
extends React.ComponentProps<"div"> {
/** Framework name shown in the header. */
framework?: string;
controls?: ComplianceControl[];
/** Disables the progress-bar fill-in. */
static?: boolean;
}
const STATUS_META: Record<
ControlStatus,
{ icon: React.ElementType; ring: string; bg: string; label: string }
> = {
met: { icon: Check, ring: "text-success", bg: "bg-success/12", label: "Met" },
"in-progress": {
icon: Clock,
ring: "text-warning",
bg: "bg-warning/12",
label: "In progress",
},
gap: { icon: X, ring: "text-destructive", bg: "bg-destructive/12", label: "Gap" },
};
const DEFAULT_CONTROLS: ComplianceControl[] = [
{ id: "CC6.1", label: "Logical access controls enforced", status: "met", evidence: "Okta policy export" },
{ id: "CC6.6", label: "Encryption in transit (TLS 1.2+)", status: "met", evidence: "SSL Labs A+ scan" },
{ id: "CC7.2", label: "Security monitoring & alerting", status: "in-progress", evidence: "Datadog dashboard" },
{ id: "CC7.3", label: "Incident response plan tested", status: "in-progress" },
{ id: "CC8.1", label: "Change management approvals", status: "met", evidence: "GitHub PR reviews" },
{ id: "CC9.2", label: "Vendor risk assessments current", status: "gap" },
];
/**
* A SOC 2 / ISO control checklist with per-control evidence status and an
* overall completion bar. The bar fills via a CSS width-free transform on a
* scaled track (scaleX with fixed origin) when the card enters view — no layout
* animation. Each row shows a met / in-progress / gap chip. Reduced motion
* renders the final fill immediately.
*/
export function ComplianceChecklist({
framework = "SOC 2 Type II",
controls = DEFAULT_CONTROLS,
static: isStatic = false,
className,
...props
}: ComplianceChecklistProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reduced;
const armed = !animate || inView;
const met = controls.filter((c) => c.status === "met").length;
const total = controls.length || 1;
const pct = Math.round((met / total) * 100);
const shown = armed ? pct : 0;
return (
<div
ref={ref}
data-slot="compliance-checklist"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">{framework}</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
<span className="tabular-nums">{met}</span> of{" "}
<span className="tabular-nums">{controls.length}</span> controls met
</p>
</div>
<span className="text-2xl font-semibold tabular-nums">{shown}%</span>
</div>
{/* Progress bar — scaleX fill, no layout animation */}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full origin-left rounded-full bg-success"
style={{
transform: `scaleX(${shown / 100})`,
transition: animate
? "transform 700ms var(--ease-out)"
: undefined,
}}
/>
</div>
<ul className="mt-4 divide-y">
{controls.map((control) => {
const meta = STATUS_META[control.status];
const Icon = meta.icon;
return (
<li key={control.id} className="flex items-center gap-3 py-2.5">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full",
meta.bg,
meta.ring,
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium">
<span className="text-muted-foreground tabular-nums">
{control.id}
</span>{" "}
{control.label}
</p>
{control.evidence && (
<p className="mt-0.5 flex items-center gap-1 text-[11px] text-muted-foreground">
<FileText className="size-3" />
{control.evidence}
</p>
)}
</div>
<span className={cn("shrink-0 text-[11px] font-medium", meta.ring)}>
{meta.label}
</span>
</li>
);
})}
</ul>
</div>
);
}