Approval Card

AI & Agents

Human-in-the-loop questions the agent asks before acting.

Interactive Live Preview
components/ui/approval-card.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
4import { Button } from "@/components/atoms/Button";
5
6/* ─────────────────────────────────────────────────────────
7 * APPROVAL CARD (human-in-the-loop)
8 * One question at a time. The stack slides vertically as you
9 * move between questions (the card's height animates to fit),
10 * the step counter rolls like an odometer, and the footer uses
11 * pill actions — a quiet Skip and a dark Continue with a ⏎.
12 * Single-choice answers auto-advance; multi-select waits.
13 * ───────────────────────────────────────────────────────── */
14
15const QUESTIONS = [
16 {
17 q: "How many flavors should we launch?",
18 type: "radio" as const,
19 options: ["Three (core line)", "Five (full case)", "Just one hero"],
20 },
21 {
22 q: "Which mix-ins should we stock?",
23 type: "check" as const,
24 options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
25 },
26 {
27 q: "Which market do we enter first?",
28 type: "radio" as const,
29 options: ["Food trucks", "Grocery freezers", "Scoop shops"],
30 },
31];
32
33const ROLL_MS = 400;
34const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
35
36/* odometer digits — each character that changes rolls up (or down) */
37function RollingDigits({ value }: { value: string }) {
38 const prevRef = useRef(value);
39 const [oldVal, setOldVal] = useState(value);
40 const [newVal, setNewVal] = useState(value);
41 const [rolling, setRolling] = useState(false);
42 const [shifted, setShifted] = useState(false);
43 const [dir, setDir] = useState<"up" | "down">("up");
44
45 useEffect(() => {
46 if (prevRef.current === value) return;
47 const from = prevRef.current;
48 prevRef.current = value;
49 const fromN = parseInt(from, 10);
50 const toN = parseInt(value, 10);
51 setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
52 setOldVal(from);
53 setNewVal(value);
54 setRolling(true);
55 setShifted(false);
56
57 let raf2 = 0;
58 const raf1 = requestAnimationFrame(() => {
59 raf2 = requestAnimationFrame(() => setShifted(true));
60 });
61 const done = setTimeout(() => {
62 setRolling(false);
63 setOldVal(value);
64 setShifted(false);
65 }, ROLL_MS);
66
67 return () => {
68 cancelAnimationFrame(raf1);
69 cancelAnimationFrame(raf2);
70 clearTimeout(done);
71 };
72 }, [value]);
73
74 const chars = rolling ? newVal : oldVal;
75
76 return (
77 <>
78 {Array.from({ length: chars.length }, (_, i) => {
79 const o = oldVal[i] ?? "";
80 const n = chars[i] ?? "";
81 if (!rolling || o === n) {
82 return <span key={`${i}-${n}`}>{n}</span>;
83 }
84 const top = dir === "down" ? n : o;
85 const bottom = dir === "down" ? o : n;
86 const restY = dir === "down" ? "0" : "-1em";
87 const startY = dir === "down" ? "-1em" : "0";
88 return (
89 <span
90 key={`${i}-${o}-${n}-${dir}`}
91 style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
92 >
93 <span
94 style={{
95 display: "flex",
96 flexDirection: "column",
97 transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
98 transform: `translateY(${shifted ? restY : startY})`,
99 }}
100 >
101 <span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
102 <span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
103 </span>
104 </span>
105 );
106 })}
107 </>
108 );
109}
110
111function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
112 return (
113 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
114 {path}
115 </svg>
116 );
117}
118
119export default function ApprovalCard({
120 onSubmitted,
121 resettable = true,
122}: {
123 onSubmitted?: () => void;
124 resettable?: boolean;
125 variant?: string;
126} = {}) {
127 const [qi, setQi] = useState(0);
128 const [answers, setAnswers] = useState<Record<number, number[]>>({});
129 const [custom, setCustom] = useState<Record<number, string>>({});
130 const [sent, setSent] = useState(false);
131 const [open, setOpen] = useState(true);
132
133 const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
134 const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
135 const measured = useRef(false);
136 const [viewportH, setViewportH] = useState<number | undefined>(undefined);
137 const [trackY, setTrackY] = useState(0);
138 const [animate, setAnimate] = useState(false);
139
140 const last = qi === QUESTIONS.length - 1;
141 const selected = answers[qi] ?? [];
142 const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
143
144 const sync = (withAnim: boolean) => {
145 const item = questionRefs.current[qi];
146 if (!item) return;
147 const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
148 setViewportH(item.offsetHeight);
149 setTrackY(item.offsetTop);
150 setAnimate(withAnim && !reduce);
151 };
152
153 useLayoutEffect(() => {
154 const withAnim = measured.current;
155 measured.current = true;
156 sync(withAnim);
157 // eslint-disable-next-line react-hooks/exhaustive-deps
158 }, [qi, answers, custom, open, sent]);
159
160 useEffect(() => {
161 const id = requestAnimationFrame(() => sync(measured.current));
162 return () => cancelAnimationFrame(id);
163 // eslint-disable-next-line react-hooks/exhaustive-deps
164 }, [qi]);
165
166 useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
167
168 const goTo = (next: number) => {
169 if (advanceTimer.current) clearTimeout(advanceTimer.current);
170 setQi(Math.min(Math.max(next, 0), QUESTIONS.length - 1));
171 };
172
173 const send = () => {
174 if (advanceTimer.current) clearTimeout(advanceTimer.current);
175 setSent(true);
176 onSubmitted?.();
177 };
178
179 const advance = () => {
180 if (last) send();
181 else goTo(qi + 1);
182 };
183
184 const toggle = (index: number) => {
185 const type = QUESTIONS[qi].type;
186 setAnswers((current) => {
187 const picked = current[qi] ?? [];
188 const next = type === "radio"
189 ? [index]
190 : picked.includes(index)
191 ? picked.filter((item) => item !== index)
192 : [...picked, index];
193 return { ...current, [qi]: next };
194 });
195 if (type === "radio") {
196 setCustom((current) => ({ ...current, [qi]: "" }));
197 if (advanceTimer.current) clearTimeout(advanceTimer.current);
198 advanceTimer.current = setTimeout(() => {
199 if (last) send();
200 else setQi((current) => Math.min(QUESTIONS.length - 1, current + 1));
201 }, 480);
202 }
203 };
204
205 const reset = () => {
206 setQi(0);
207 setAnswers({});
208 setCustom({});
209 setSent(false);
210 setOpen(true);
211 measured.current = false;
212 };
213
214 if (!open) {
215 return (
216 <button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
217 Open approval
218 </button>
219 );
220 }
221
222 if (sent) {
223 return (
224 <div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
225 <span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
226 <span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
227 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
228 </span>
229 Answers sent
230 </span>
231 {resettable && (
232 <button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
233 Start over
234 </button>
235 )}
236 </div>
237 );
238 }
239
240 return (
241 <div className="w-full max-w-80">
242 <div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
243 <button
244 type="button"
245 aria-label="Dismiss"
246 onClick={() => setOpen(false)}
247 className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
248 >
249 <Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
250 </button>
251 <div className="primitive-card-pad">
252 {/* the question itself is the heading */}
253 <div
254 className="overflow-hidden"
255 style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
256 aria-live="polite"
257 >
258 <div
259 style={{
260 display: "flex",
261 flexDirection: "column",
262 gap: 26,
263 transform: `translate3d(0, ${-trackY}px, 0)`,
264 transition: animate ? `transform ${SLIDE}` : undefined,
265 willChange: "transform",
266 }}
267 >
268 {QUESTIONS.map((question, qIdx) => {
269 const active = qIdx === qi;
270 const picked = answers[qIdx] ?? [];
271 const questionStyle: CSSProperties = {
272 opacity: active ? 1 : 0,
273 transition: animate ? `opacity ${SLIDE}` : undefined,
274 pointerEvents: active ? undefined : "none",
275 };
276 return (
277 <div
278 key={qIdx}
279 ref={(el) => { questionRefs.current[qIdx] = el; }}
280 aria-hidden={active ? undefined : true}
281 style={questionStyle}
282 >
283 <div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
284 <div className="mt-2 flex flex-col gap-0.5">
285 {question.options.map((option, i) => {
286 const on = picked.includes(i);
287 return (
288 <button
289 key={option}
290 type="button"
291 aria-pressed={on}
292 tabIndex={active ? 0 : -1}
293 onClick={() => { if (active) toggle(i); }}
294 className="flex items-center gap-2 rounded-control px-1.5 py-1 text-left transition-colors duration-100 hover:bg-hover"
295 >
296 <span
297 className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
298 ${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
299 ${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
300 >
301 {question.type === "radio" ? (
302 <span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
303 ) : (
304 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
305 )}
306 </span>
307 <span className={`text-[13px] transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
308 {option}
309 </span>
310 </button>
311 );
312 })}
313 <label className="flex items-center gap-2 rounded-control px-1.5 py-1 transition-colors duration-100 focus-within:bg-hover hover:bg-hover">
314 <span aria-hidden="true" className="size-4 shrink-0" />
315 <input
316 value={custom[qIdx] ?? ""}
317 tabIndex={active ? 0 : -1}
318 onChange={(event) => {
319 if (!active) return;
320 setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
321 if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
322 }}
323 onKeyDown={(event) => {
324 if (event.key === "Enter" && hasAnswer) {
325 event.preventDefault();
326 advance();
327 }
328 }}
329 placeholder="Something else…"
330 aria-label="Custom answer"
331 className="min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3"
332 />
333 </label>
334 </div>
335 </div>
336 );
337 })}
338 </div>
339 </div>
340 </div>
341
342 {/* footer — step nav (rolling counter) + pill actions */}
343 <div className="primitive-card-footer flex items-center justify-between gap-3">
344 <div className="flex items-center gap-1 text-ink-3">
345 <button
346 type="button"
347 aria-label="Previous question"
348 disabled={qi <= 0}
349 onClick={() => goTo(qi - 1)}
350 className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
351 >
352 <Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
353 </button>
354 <span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
355 <RollingDigits value={`${qi + 1} / ${QUESTIONS.length}`} />
356 </span>
357 <button
358 type="button"
359 aria-label="Next question"
360 disabled={last}
361 onClick={() => goTo(qi + 1)}
362 className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
363 >
364 <Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
365 </button>
366 </div>
367
368 <div className="-mr-0.5 flex items-center gap-1.5">
369 <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
370 Skip
371 </Button>
372 <Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
373 {last ? "Send" : "Continue"}
374 </Button>
375 </div>
376 </div>
377 </div>
378 </div>
379 );
380}
381

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents