opsinjs
ComponentsHealth communication

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

Theme
Density
Text
Status
consent-sheet · base base · style base-lyraOpen under the product theme

Installation

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 card instead.
  • The reader must simply acknowledge something before continuing, with no meaningful choice.Use dialog instead.
  • You need an operating-system permission, such as notifications; the platform owns that.Use sheet instead.
  • The message is the standing not-medical-advice note, not a request.Use disclaimer-note instead.
  • You are collecting the data itself rather than the permission to collect it.Use field instead.

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

  1. ConsentSheetSheet's container. open, onOpenChange, detents, modal and dismissible pass through.Controlled by open
  2. ConsentSheet.HeadingThe question, as Sheet's title and the popup's aria-labelledby.Controlled by heading
  3. ConsentSheet.PurposeWhat the data is for, through Sheet.Description, the popup's aria-describedby.Controlled by purpose
  4. ConsentSheet.ScopeA ul of three facts: collected, shared with, retention.Controlled by scope
  5. ConsentSheet.DetailsOptional full wording behind a named trigger, kept in the DOM while collapsed.Controlled by details
  6. ConsentSheet.WithdrawalWhere to change this later. Mandatory, and text, not a link.Controlled by withdrawalPath
  7. ConsentSheet.ConsequenceWhat a refusal costs, read before the choice, not after.Controlled by consequenceOfDeclining
  8. ConsentSheet.DecisionsPinned footer, a two-track grid. Decline first, Accept second, same Button, same size.Controlled by declineLabel
  9. ConsentSheet.NotAskedOne line replacing the controls when required wording is missing.Controlled by notAskedMessage

Examples

Press a control and onDecision receives a record. Leave by any other exit and nothing arrives: no record, no false.

Theme
Density
Text
Status
consent-sheet-closing-is-not-consent · base base · style base-lyraOpen under the product theme

Two 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.

Theme
Density
Text
Status
consent-sheet-two-questions-two-sheets · base base · style base-lyraOpen under the product theme

When 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.

Theme
Density
Text
Status
consent-sheet-no-wording-supplied · base base · style base-lyraOpen under the product theme

Content 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.

  • heading is the dialog's accessible name and purpose its description; focus lands on the sheet. The two decisions are one grid, one Button variant 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 with aria-expanded and aria-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.

KeyActionNotes
TabMoves through the sheet in DOM order: close, scrolling region, disclosure if present, decline, acceptFocus starts on the sheet.
Shift+TabThe same order in reverseFocus is trapped while modal, so the order wraps.
EnterActivates the control that has focus
SpaceActivates the control that has focusIdentical to Enter.
EscapeCloses the sheet without deciding, returning focus to the openeronDecision is not called. onOpenChange reports route "escape".
Arrow keysNothingTwo tab stops, not a radio group.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page