ConsentSheet
A sheet that asks permission for one specific thing at a time, explains what it is for, and makes saying no or changing your mind as easy as saying yes.
Preview
consent-sheet · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/consent-sheet
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { ConsentSheet } from "@/components/ui/consent-sheet"<ConsentSheet
open={open}
onOpenChange={setOpen}
consentId="example-consent"
textVersion={copy.version}
heading={copy.question}
purpose={copy.purpose}
scope={copy.scope}
withdrawalPath={copy.withdrawal}
acceptLabel={copy.accept}
declineLabel={copy.decline}
onDecision={store}
/>When to use it
Use it when
- Asking permission to collect, use or share a specific kind of health data.
- Re-asking for a permission whose scope has changed, so the earlier decision no longer covers it.
- Confirming a decision with consequences the reader should see first, such as sharing a record.
Do not use it when
- Showing permissions already given, to review or withdraw them.Use
cardinstead. - The reader must simply acknowledge something before continuing, with no meaningful choice.Use
dialoginstead. - You need an operating-system permission, such as notifications; the platform owns that.Use
sheetinstead. - The message is the standing not-medical-advice note, not a request.Use
disclaimer-noteinstead. - You are collecting the data itself rather than the permission to collect it.Use
fieldinstead.
Clinical meaning
Asserts. One specific permission is being asked for. The reader has been told what is collected, what it is for, who sees it and how long it is kept.
Never read as. A condition of using the product, unless it genuinely is one and the sheet says so. Never as permanent: the sheet says where to withdraw. See Consent and disclosure.
Colour axis. Neither. No element carries data-status or data-category.
See Clinical interaction guidelines.
Thresholds. None; the sheet displays no reading. Every consent string arrives as a prop, because the wording is the product's legal statement (Regulatory context).
Closing is not deciding. onDecision fires only when a reader presses one of the two
controls. Escape, scrim, drag and header Close call nothing, so did not answer is the
absence of a call, never a stored false.
Anatomy
ConsentSheetSheet's container. open, onOpenChange, detents, modal and dismissible pass through.Controlled byopenConsentSheet.HeadingThe question, as Sheet's title and the popup's aria-labelledby.Controlled byheadingConsentSheet.PurposeWhat the data is for, through Sheet.Description, the popup's aria-describedby.Controlled bypurposeConsentSheet.ScopeA ul of three facts: collected, shared with, retention.Controlled byscopeConsentSheet.DetailsOptional full wording behind a named trigger, kept in the DOM while collapsed.Controlled bydetailsConsentSheet.WithdrawalWhere to change this later. Mandatory, and text, not a link.Controlled bywithdrawalPathConsentSheet.ConsequenceWhat a refusal costs, read before the choice, not after.Controlled byconsequenceOfDecliningConsentSheet.DecisionsPinned footer, a two-track grid. Decline first, Accept second, same Button, same size.Controlled bydeclineLabelConsentSheet.NotAskedOne line replacing the controls when required wording is missing.Controlled bynotAskedMessage
Examples
Closing is not consent
Press a control and onDecision receives a record. Leave by any other exit and nothing
arrives: no record, no false.
consent-sheet-closing-is-not-consent · base base · style base-lyraOpen under the product themeTwo questions, two sheets
No purposes array exists, so two permissions are two sheets and two records. The second
follows an answer, never a dismissal, and shows consequenceOfDeclining.
consent-sheet-two-questions-two-sheets · base base · style base-lyraOpen under the product themeWhen the wording is missing
Any required string blank or absent means the sheet refuses to ask, showing the not-asked line and naming the missing fields in the console.
consent-sheet-no-wording-supplied · base base · style base-lyraOpen under the product themeContent guidelines
Ask a question, with we for the product and you for the reader. Say what the reader gets, not what the product does. Never write by continuing you agree, and never not now when it means no. The component warns in development, then renders it as written.
Do
Both labels written as the answer they are, the refusal naming what will not happen, at the same size.
Don’t
A large filled "Allow" with a small grey "Not now" beneath. The hierarchy is the question.
Accessibility
Audited against WCAG 2.2 AA, source pass and rendered axe pass. The audit is author-run, not an independent review, and clinical review is still pending.
Gated on every commit by pnpm run check:a11y: no px size, no raw colour, no banned
word, no element resolving a category or status property.
headingis the dialog's accessible name andpurposeits description; focus lands on the sheet. The two decisions are one grid, oneButtonvariant at one size, decline first, never stacked, and the copy carries every meaning so nothing rides on colour. The details trigger is a real button witharia-expandedandaria-controls.
What the audit changed. One fix: the disclosure trigger's focus ring now uses the
--opsin-border-focus token the decision buttons share, so a theme that thickens the focus
ring reaches it too. A ring was already visible, so this closes no gap.
Residual. With heading omitted the sheet refuses to ask and the dialog then has no
accessible name; the component dev-warns, so this is invalid usage, not a supported state.
The open sheet could not be exercised on the stale port 4000 build, so those verdicts rest on
source. Focus trap, focus return, dismissal and the swipe close alternative belong to
Sheet and are verified there.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves through the sheet in DOM order: close, scrolling region, disclosure if present, decline, accept | Focus starts on the sheet. |
| Shift+Tab | The same order in reverse | Focus is trapped while modal, so the order wraps. |
| Enter | Activates the control that has focus | |
| Space | Activates the control that has focus | Identical to Enter. |
| Escape | Closes the sheet without deciding, returning focus to the opener | onDecision is not called. onOpenChange reports route "escape". |
| Arrow keys | Nothing | Two tab stops, not a radio group. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from ConsentSheetProps in registry/bases/base/consent-sheet.tsx.
interface ConsentScope { collected: string; sharedWith: string; retention: string }
interface ConsentDetails { label: string; content: ReactNode }
interface ConsentDecision {
granted: boolean; at: string; consentId: string; textVersion: string; scope: ConsentScope
}ConsentSheetProps extends SheetProps with title,
children and footer removed, because heading is the title. A whitespace-only string
counts as missing. There is no purposes, defaultGranted, required, hideDecline,
acceptVariant or declineVariant.
Related
- Sheet is the surface underneath, with none of the contract.
- Dialog is for a decision with no consent semantics and nothing to record.
- DisclaimerNote is a statement, not a request.
- CareCard asks the reader to act in the world, not to permit something.