Lists blank, partial and flagged questions before a test or survey is submitted, with jump links and time left.
StopsTest submitted with questions skipped
"use client"
import * as React from "react"
import { UnansweredCheck, type ExamQuestion } from "@/components/ui/unanswered-check"
const initial: ExamQuestion[] = Array.from({ length: 20 }, (_, i) => {
const n = i + 1
return {
id: `q${n}`,
answered: [4, 9, 12, 15].includes(n) ? false : n === 7 ? "partial" : true,
required: n !== 15,
flagged: n === 3 || n === 9,
note: n === 7 ? "1 of 2 chosen" : undefined,
}
})
export function Demo() {
const [questions, setQuestions] = React.useState(initial)
const [endsAt, setEndsAt] = React.useState<number>()
const [current, setCurrent] = React.useState<string>()
const [submitted, setSubmitted] = React.useState(false)
React.useEffect(() => setEndsAt(Date.now() + (4 * 60 + 12) * 1000), [])
const answer = (id: string) => {
setQuestions((qs) => qs.map((q) => (q.id === id ? { ...q, answered: true, saving: true } : q)))
setTimeout(() => setQuestions((qs) => qs.map((q) => (q.id === id ? { ...q, saving: false } : q))), 900)
}
if (submitted) return <p className="text-sm font-medium">Submitted. You can close this tab.</p>
return (
<div className="grid w-full max-w-md gap-3">
{current && (
<div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
<span>Question {current.slice(1)}</span>
<button type="button" onClick={() => answer(current)} className="h-8 rounded-md border px-3 text-sm font-medium hover:bg-accent">
Mark answered
</button>
</div>
)}
<UnansweredCheck
questions={questions}
endsAt={endsAt}
onJump={setCurrent}
onSubmit={() => new Promise<void>((resolve) => setTimeout(() => (setSubmitted(true), resolve()), 700))}
/>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uiunanswered-check.tsx308 lines"use client" // Bollard UI — unanswered-check · MIT licence · https://bollard-ui.vercel.app/components/unanswered-check import * as React from "react" import { CircleDashed, CircleHelp, Flag, Hourglass, LoaderCircle, SquareSplitHorizontal } from "lucide-react" import { cn } from "@/lib/utils" export interface ExamQuestion { id: string /** Label shown on its jump link. Defaults to its position, 1-based. */ label?: string /** `"partial"` for a question with parts left, such as 1 of 2 choices picked. */ answered: boolean | "partial" /** Optional questions can be left blank without penalty. Defaults to required. */ required?: boolean /** Marked for review by the candidate. */ flagged?: boolean /** The answer hasn't reached the server yet. */ saving?: boolean /** Short detail for a partial answer, e.g. "1 of 2 chosen". */ note?: string } /** Questions grouped by what still needs attention, in question order. */ export function summarizeAnswers(questions: ExamQuestion[]) { const items = questions.map((q, i) => ({ ...q, label: q.label ?? String(i + 1) })) const open = (q: ExamQuestion) => q.answered === false return { required: items.filter((q) => open(q) && q.required !== false), optional: items.filter((q) => open(q) && q.required === false), partial: items.filter((q) => q.answered === "partial"), flagged: items.filter((q) => q.flagged), saving: items.filter((q) => q.saving), answered: items.filter((q) => q.answered === true).length, total: items.length, } } /** "4:05", "1:02:09". */ export function formatTimeLeft(ms: number) { const s = Math.max(0, Math.ceil(ms / 1000)) const [h, m, sec] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60] const pad = (n: number) => String(n).padStart(2, "0") return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}` } const fill = (t: string, v: Record<string, string | number>) => t.replace(/\{(\w+)\}/g, (m, k: string) => String(v[k] ?? m)) const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` const button = "inline-flex h-9 items-center justify-center gap-1.5 rounded-md px-4 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-60" export interface UnansweredCheckProps extends Omit<React.ComponentProps<"section">, "children"> { /** Every question with its answered, required, flagged and saving state. */ questions: ExamQuestion[] /** `exam`: blanks are allowed but cost marks, so submitting with them takes a second click. `survey`: required questions must be answered. */ mode?: "exam" | "survey" /** When time runs out. Leave out for untimed tests. */ endsAt?: Date | number /** Fixed "now" for previews and tests. Defaults to a live clock. */ now?: Date | number /** Turns the timer amber below this many ms; red below a fifth of it. */ lowTime?: number /** Go to a question. */ onJump: (id: string) => void /** Called once when the time runs out, so the host can auto-submit. */ onTimeUp?: () => void /** Final submit. Return a promise to show a pending state; a rejection keeps the answers and says so. */ onSubmit: () => void | Promise<void> /** Heading of the check. */ heading?: string /** Heading for unanswered required questions. */ requiredLabel?: string /** Heading for unanswered optional questions. */ optionalLabel?: string /** Heading for partly answered questions. */ partialLabel?: string /** Heading for questions flagged for review. */ flaggedLabel?: string /** Shown when everything is answered. */ allDoneText?: string /** Templates: `{count}` and `{each}` are filled in. */ autoSubmitText?: string /** Time per open question. */ paceText?: string /** Warning in the last seconds. */ lastSecondsText?: string /** Label of the submit button. */ submitLabel?: string /** Label when blanks remain. */ submitWithBlanksLabel?: string /** Label of the second click to submit with blanks. */ confirmLabel?: string /** Label when required questions block submit. */ needRequiredLabel?: string /** Label while answers are saving. */ savingLabel?: string /** Label while onSubmit runs. */ pendingLabel?: string /** Shown when onSubmit rejects. */ errorText?: string /** Accessible label of a question jump button. */ goToLabel?: (question: string, note?: string) => string /** Screen-reader note on a flagged question that has no answer. */ notAnsweredSrText?: string } /** * Stops a test or survey being submitted with questions skipped by accident. Before the final * submit it lists, by number with jump links, the required and optional questions still blank, * partly answered ones and those flagged for review, with time left and how much of it each open * question gets. Blanks in an exam take a deliberate second click, required survey questions * block, and submit waits until every answer has actually saved. */ export function UnansweredCheck({ questions, mode = "exam", endsAt, now: fixedNow, lowTime = 5 * 60_000, onJump, onTimeUp, onSubmit, heading = "Before you submit", requiredLabel = "Not answered", optionalLabel = "Optional, not answered", partialLabel = "Partly answered", flaggedLabel = "Flagged for review", allDoneText = "Every question is answered.", autoSubmitText = "At 0:00 your answers are submitted as they are.", paceText = "About {each} for each of the {count} still open.", lastSecondsText = "Submit now: the last seconds can be lost to a slow connection.", submitLabel = "Submit", submitWithBlanksLabel = "Submit with {count}", confirmLabel = "Blanks score 0. Submit anyway", needRequiredLabel = "Answer {count} required to submit", savingLabel = "Saving {count}…", pendingLabel = "Submitting…", errorText = "Not submitted. Your answers are kept, try again.", goToLabel = (question, note) => `Go to question ${question}${note ? `, ${note}` : ""}`, notAnsweredSrText = ", not answered", className, ...props }: UnansweredCheckProps) { const [tick, setTick] = React.useState<number>() const [armed, setArmed] = React.useState(false) const [state, setState] = React.useState<"idle" | "pending" | "error">("idle") const armedAt = React.useRef(0) const timeUpFired = React.useRef(false) const summary = summarizeAnswers(questions) const blanks = summary.required.length + summary.optional.length const blocked = mode === "survey" && summary.required.length > 0 const open = summary.required.length + summary.partial.length + (mode === "exam" ? summary.optional.length : 0) React.useEffect(() => { if (fixedNow !== undefined || endsAt === undefined) return setTick(Date.now()) const id = setInterval(() => setTick(Date.now()), 1000) return () => clearInterval(id) }, [fixedNow, endsAt]) // Arming lasts a few seconds, and any change to the answers disarms it. React.useEffect(() => { if (!armed) return const id = setTimeout(() => setArmed(false), 4000) return () => clearTimeout(id) }, [armed]) React.useEffect(() => setArmed(false), [blanks, open]) const now = fixedNow !== undefined ? new Date(fixedNow).getTime() : tick const left = endsAt !== undefined && now !== undefined ? Math.max(0, new Date(endsAt).getTime() - now) : undefined const tone = left === undefined || left > lowTime ? "calm" : left > lowTime / 5 ? "low" : "critical" const each = left !== undefined && open > 0 ? left / open : undefined const eachText = each === undefined ? "" : each >= 90_000 ? `${Math.floor(each / 60_000)} min` : `${Math.floor(each / 1000)} s` React.useEffect(() => { if (left !== 0 || timeUpFired.current) return timeUpFired.current = true onTimeUp?.() }, [left, onTimeUp]) const submit = async () => { if (blocked) return onJump(summary.required[0].id) if (blanks > 0 && mode === "exam" && !armed) { armedAt.current = Date.now() return setArmed(true) } // A double-click arms and would confirm in one motion; ignore a second click that fast. if (armed && Date.now() - armedAt.current < 400) return setArmed(false) setState("pending") try { await onSubmit() setState("idle") } catch { setState("error") } } const groups = [ { key: "required", label: requiredLabel, items: summary.required, icon: CircleHelp, tone: "border-red-500/50 text-red-700 dark:text-red-400" }, { key: "partial", label: partialLabel, items: summary.partial, icon: SquareSplitHorizontal, tone: "border-amber-500/50 text-amber-800 dark:text-amber-300" }, { key: "flagged", label: flaggedLabel, items: summary.flagged, icon: Flag, tone: "border-amber-500/50 text-amber-800 dark:text-amber-300" }, { key: "optional", label: optionalLabel, items: summary.optional, icon: CircleDashed, tone: "border-dashed text-muted-foreground" }, ].filter((g) => g.items.length > 0) return ( <section data-slot="unanswered-check" aria-label={heading} className={cn("grid w-full max-w-md gap-3 rounded-lg border bg-card p-4 text-sm text-card-foreground", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-2"> <div> <h2 className="font-semibold">{heading}</h2> <p className="text-xs text-muted-foreground tabular-nums"> {summary.answered} of {summary.total} answered </p> </div> {endsAt !== undefined && ( <p role="timer" aria-label={left === undefined ? undefined : `${formatTimeLeft(left)} left`} className={cn( "flex items-center gap-1.5 rounded-md border px-2 py-1 font-medium tabular-nums", tone === "low" && "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300", tone === "critical" && "border-red-500/40 bg-red-500/10 text-red-700 motion-safe:animate-pulse dark:text-red-400", )} > <Hourglass aria-hidden className="size-3.5" /> {left === undefined ? "–:––" : formatTimeLeft(left)} </p> )} </div> {groups.length === 0 ? ( <p className="text-emerald-700 dark:text-emerald-400">{allDoneText}</p> ) : ( <div className="grid gap-2.5"> {groups.map(({ key, label, items, icon: Icon, tone: chip }) => ( <div key={key} className="grid gap-1.5"> <p className="flex items-center gap-1.5 text-xs font-medium"> <Icon aria-hidden className="size-3.5 text-muted-foreground" /> {label} <span className="text-muted-foreground tabular-nums">· {items.length}</span> </p> <ul className="flex flex-wrap gap-1.5"> {items.map((q) => ( <li key={q.id}> <button type="button" onClick={() => onJump(q.id)} title={q.note} aria-label={goToLabel(String(q.label), q.note)} className={cn( "inline-flex h-8 min-w-8 items-center justify-center gap-1 rounded-md border bg-background px-2 text-xs font-medium tabular-nums transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50", chip, key === "flagged" && q.answered === false && "border-red-500/50 text-red-700 dark:text-red-400", )} > {q.label} {key === "partial" && q.note && <span className="font-normal opacity-80">· {q.note}</span>} {key === "flagged" && q.answered !== true && <span className="sr-only">{notAnsweredSrText}</span>} </button> </li> ))} </ul> </div> ))} </div> )} {left !== undefined && ( <div aria-live="polite" className="grid gap-1 text-xs text-muted-foreground"> {each !== undefined && <p>{fill(paceText, { each: eachText, count: open })}</p>} <p className={cn(tone === "critical" && "font-medium text-red-700 dark:text-red-400")}>{tone === "critical" ? lastSecondsText : autoSubmitText}</p> </div> )} <div className="grid gap-1.5"> <button type="button" onClick={submit} disabled={summary.saving.length > 0 || state === "pending"} className={cn( button, "w-full", armed ? "bg-destructive text-white hover:bg-destructive/90" : blocked ? "border bg-background hover:bg-accent" : "bg-primary text-primary-foreground hover:bg-primary/90", )} > {(summary.saving.length > 0 || state === "pending") && <LoaderCircle aria-hidden className="size-4 animate-spin" />} {summary.saving.length > 0 ? fill(savingLabel, { count: plural(summary.saving.length, "answer") }) : state === "pending" ? pendingLabel : blocked ? fill(needRequiredLabel, { count: summary.required.length }) : armed ? confirmLabel : blanks > 0 && mode === "exam" ? fill(submitWithBlanksLabel, { count: plural(blanks, "blank") }) : submitLabel} </button> {state === "error" && ( <p role="alert" className="text-xs text-red-700 dark:text-red-400"> {errorText} </p> )} </div> </section> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { UnansweredCheck, type ExamQuestion } from "@/components/ui/unanswered-check"
<UnansweredCheck
questions={questions}
endsAt={endsAt}
onJump={setCurrent}
onSubmit={() => new Promise<void>((resolve) => setTimeout(() => (setSubmitted(true), resolve()), 700))}
/>What it catches
- Required and optional questions left blank
- Partly answered questions ("1 of 2 chosen")
- Questions flagged for review
- Answers not yet saved to the server
- Time left and how much each open question gets
Props
| Prop | Type | Default |
|---|---|---|
questions* (required)Every question with its answered, required, flagged and saving state. | ExamQuestion[] | — |
mode`exam`: blanks are allowed but cost marks, so submitting with them takes a second click. `survey`: required questions must be answered. | "exam" | "survey" | "exam" |
endsAtWhen time runs out. Leave out for untimed tests. | Date | number | — |
nowFixed "now" for previews and tests. Defaults to a live clock. | Date | number | — |
lowTimeTurns the timer amber below this many ms; red below a fifth of it. | number | 5 * 60_000 |
onJump* (required)Go to a question. | (id: string) => void | — |
onTimeUpCalled once when the time runs out, so the host can auto-submit. | () => void | — |
onSubmit* (required)Final submit. Return a promise to show a pending state; a rejection keeps the answers and says so. | () => void | Promise<void> | — |
headingHeading of the check. | string | "Before you submit" |
requiredLabelHeading for unanswered required questions. | string | "Not answered" |
optionalLabelHeading for unanswered optional questions. | string | "Optional, not answered" |
partialLabelHeading for partly answered questions. | string | "Partly answered" |
flaggedLabelHeading for questions flagged for review. | string | "Flagged for review" |
allDoneTextShown when everything is answered. | string | "Every question is answered." |
autoSubmitTextTemplates: `{count}` and `{each}` are filled in. | string | "At 0:00 your answers are submitted as they are." |
paceTextTime per open question. | string | "About {each} for each of the {count} still open." |
lastSecondsTextWarning in the last seconds. | string | "Submit now: the last seconds can be lost to a slow conne… |
submitLabelLabel of the submit button. | string | "Submit" |
submitWithBlanksLabelLabel when blanks remain. | string | "Submit with {count}" |
confirmLabelLabel of the second click to submit with blanks. | string | "Blanks score 0. Submit anyway" |
needRequiredLabelLabel when required questions block submit. | string | "Answer {count} required to submit" |
savingLabelLabel while answers are saving. | string | "Saving {count}…" |
pendingLabelLabel while onSubmit runs. | string | "Submitting…" |
errorTextShown when onSubmit rejects. | string | "Not submitted. Your answers are kept, try again." |
goToLabelAccessible label of a question jump button. | (question: string, note?: string) => string | (question, note) => `Go to question ${question}${note ? `… |
notAnsweredSrTextScreen-reader note on a flagged question that has no answer. | string | ", not answered" |
* required. Also accepts the native props of <section> (className, aria-*, data-*…), passed through to it.
Accessibility
- Section labelled by its heading; countdown is role=timer with an aria-label
- Jump links have aria-labels including notes
- Status in a polite live region; errors use role=alert
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
This is a UX guard rail. Enforce on the server: Enforce required answers and the deadline on the server; accept submissions only once all answers are saved.
- Only knows the question states you pass
- Time limits and auto-submit rely on your onTimeUp and server timing
What it stops
Test submitted with questions skipped
How people describe it
Also known as
quiz · exam · test · survey · questionnaire · mcq · assessment · google forms