---
title: "ConsentSheet"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/consent-sheet"
source: "https://opsinjs.pensievelabs.org/components/consent-sheet.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-communication"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["consent", "permission", "opt in", "data sharing", "agree"]
governedBy: ["consent-and-disclosure", "clinical-interaction-guidelines", "crisis-and-self-harm", "regulatory-context"]
usedIn: ["ask-users-for/contact-details", "consent-and-permissions", "consent-before-collection", "consent-flow", "diabetes-medicines-app", "forms/required-and-optional", "onboarding-and-first-run", "onboarding-screen", "sharing-with-a-clinician"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="consent-sheet"
  status="shipped"
  questions="[
  &#x22;Whether refusing to ask when any required wording is missing is right in every product, or whether a partial content service needs a way to ask anyway.&#x22;,
  &#x22;Withdrawal is a sentence, not a control, and nothing in opsinjs is yet the screen where the data lives, so no component keeps the sheet's promise.&#x22;,
  &#x22;No previousDecision prop exists, so a reader who refused once sees the same sheet again, and re-asking on a timer is a dark pattern the component cannot refuse.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="consent-sheet" />

## Installation [#installation]

<ComponentInstall name="consent-sheet" unbuilt="false" importPath="@/components/ui/consent-sheet" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;sheet&#x22;, &#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { ConsentSheet } from "@/components/ui/consent-sheet"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Asking permission to collect, use or share a specific kind of health data.&#x22;,
  &#x22;Re-asking for a permission whose scope has changed, so the earlier decision no longer covers it.&#x22;,
  &#x22;Confirming a decision with consequences the reader should see first, such as sharing a record.&#x22;,
]"
  avoid="[
  { case: &#x22;Showing permissions already given, to review or withdraw them.&#x22;, instead: &#x22;card&#x22; },
  { case: &#x22;The reader must simply acknowledge something before continuing, with no meaningful choice.&#x22;, instead: &#x22;dialog&#x22; },
  { case: &#x22;You need an operating-system permission, such as notifications; the platform owns that.&#x22;, instead: &#x22;sheet&#x22; },
  { case: &#x22;The message is the standing not-medical-advice note, not a request.&#x22;, instead: &#x22;disclaimer-note&#x22; },
  { case: &#x22;You are collecting the data itself rather than the permission to collect it.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Clinical meaning [#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](../health/consent-and-disclosure.mdx).

**Colour axis.** Neither. No element carries `data-status` or `data-category`.
See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).

**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](../health/regulatory-context.mdx)).

**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`.

<SafetyCallout severity="urgent" evidence="opinion">
  No pre-ticked boxes, no default-on switches, no *agree* that is also *continue*, and no
  sheet that cannot be closed without deciding.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="consent-sheet"
  parts="[
  {
    name: &#x22;ConsentSheet&#x22;,
    describes: &#x22;Sheet's container. open, onOpenChange, detents, modal and dismissible pass through.&#x22;,
    prop: &#x22;open&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Heading&#x22;,
    describes: &#x22;The question, as Sheet's title and the popup's aria-labelledby.&#x22;,
    prop: &#x22;heading&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Purpose&#x22;,
    describes: &#x22;What the data is for, through Sheet.Description, the popup's aria-describedby.&#x22;,
    prop: &#x22;purpose&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Scope&#x22;,
    describes: &#x22;A ul of three facts: collected, shared with, retention.&#x22;,
    prop: &#x22;scope&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Details&#x22;,
    describes: &#x22;Optional full wording behind a named trigger, kept in the DOM while collapsed.&#x22;,
    prop: &#x22;details&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Withdrawal&#x22;,
    describes: &#x22;Where to change this later. Mandatory, and text, not a link.&#x22;,
    prop: &#x22;withdrawalPath&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Consequence&#x22;,
    describes: &#x22;What a refusal costs, read before the choice, not after.&#x22;,
    prop: &#x22;consequenceOfDeclining&#x22;,
  },
  {
    name: &#x22;ConsentSheet.Decisions&#x22;,
    describes: &#x22;Pinned footer, a two-track grid. Decline first, Accept second, same Button, same size.&#x22;,
    prop: &#x22;declineLabel&#x22;,
  },
  {
    name: &#x22;ConsentSheet.NotAsked&#x22;,
    describes: &#x22;One line replacing the controls when required wording is missing.&#x22;,
    prop: &#x22;notAskedMessage&#x22;,
  },
]"
/>

## Examples [#examples]

### Closing is not consent [#closing-is-not-consent]

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

<ComponentPreview name="consent-sheet-closing-is-not-consent" kind="example" align="start" />

### Two questions, two sheets [#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`.

<ComponentPreview name="consent-sheet-two-questions-two-sheets" kind="example" align="start" />

### When the wording is missing [#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.

<ComponentPreview name="consent-sheet-no-wording-supplied" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **Both labels written as the answer they are**, the refusal naming what will not
    happen, at the same size.
  </DoDont.Do>

  <DoDont.Dont>
    **A large filled "Allow" with a small grey "Not now" beneath.** The hierarchy is the
    question.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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](./sheet.mdx) and are verified there.

<KeyboardTable
  name="consent-sheet"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves through the sheet in DOM order: close, scrolling region, disclosure if present, decline, accept&#x22;,
    notes: &#x22;Focus starts on the sheet.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;The same order in reverse&#x22;,
    notes: &#x22;Focus is trapped while modal, so the order wraps.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the control that has focus&#x22;,
    notes: &#x22;&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the control that has focus&#x22;,
    notes: &#x22;Identical to Enter.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes the sheet without deciding, returning focus to the opener&#x22;,
    notes: &#x22;onDecision is not called. onOpenChange reports route \&#x22;escape\&#x22;.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Two tab stops, not a radio group.&#x22;,
  },
]"
/>

<ContrastReport component="consent-sheet" />

## API reference [#api-reference]

<PropsTable name="ConsentSheetProps" />

```ts
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](./sheet.mdx#api-reference) 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 [#related]

* [Sheet](./sheet.mdx) is the surface underneath, with none of the contract.
* [Dialog](./dialog.mdx) is for a decision with no consent semantics and nothing to record.
* [DisclaimerNote](./disclaimer-note.mdx) is a statement, not a request.
* [CareCard](./care-card.mdx) asks the reader to act in the world, not to permit something.
