---
title: Consent
description: A non-modal consent bar with equal-weight answers. It renders the question and reports the click; the host owns the SDK, the storage and the law.
source: https://adamjurek.com/components/consent
---

## Home


A privacy question docked to the corner of the screen, with both answers the
same size. It renders when `open` is true, reports which button was pressed,
and does nothing else - the analytics SDK, the stored answer and the
regulation being satisfied all belong to the host.

<!-- ::start:showcase demo="consent" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

**It knows no vendor.** A consent bar hard-wired to one analytics SDK is that
vendor's plugin, not a component. `onAccept` and `onDecline` are the whole
contract, so the same bar fronts PostHog today and whatever replaces it
without touching this file.

**It is non-modal, and that is the legal shape.** The regulation this exists
for says a visitor may ignore the question and keep reading - so there is no
backdrop, no focus trap, and the page behind stays live. A consent dialog
that blocks the content answers "may I track you" with a hostage.

**Both buttons are the same size**, by a rule in the stylesheet rather than
by discipline. Declining must cost the same click as accepting; the shrunken
grey "no" is the dark pattern regulators name, and this block refuses to
render one.

## What it does not do

It does not remember the answer - keep `open` false once one is recorded,
wherever you record it. It does not block rendering, set cookies, or talk to
any network. And it does not decide *when* to ask: mount it when your own
consent state says the question is still open.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/consent.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/consent.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Overlays |
| Files | `consent.tsx` |
| Dependencies | None |
| Tags | privacy, gdpr, analytics, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useState } from "react";
import { Consent } from "@sushindustries/ui";

export function Example() {
	const [open, setOpen] = useState(true);

	return (
		<Consent
			open={open}
			onAccept={() => setOpen(false)}
			onDecline={() => setOpen(false)}
		>
			I measure page views to see what is worth writing more of. Nothing
			personal, nothing sold.
		</Consent>
	);
}
```

## What you should see

A bar docked to the corner of the screen with your question and two
equal-sized buttons, Allow and Decline. It does not dim or block the rest of
the page - that is deliberate, not a missing backdrop.

## If nothing happens

`open` starting `false` renders nothing at all, which is correct once an
answer is already recorded. If the bar never appears on a fresh visit, check
that whatever reads the stored answer defaults to "ask" rather than "denied" -
this component has no memory of its own and only ever reflects `open`.


## Guides


## Storing the answer

`Consent` reports a click and nothing else. A minimal host looks like this:

```tsx
const [status, setStatus] = useState<"pending" | "granted" | "denied">(
	() => (localStorage.getItem("consent") as typeof status) ?? "pending",
);

function record(next: "granted" | "denied") {
	localStorage.setItem("consent", next);
	setStatus(next);
}

<Consent open={status === "pending"} onAccept={() => record("granted")} onDecline={() => record("denied")}>
	...
</Consent>
```

Reading storage at initial state, rather than in an effect that flips `open`
after mount, is what stops the bar flashing open for a returning visitor
before the stored answer has been read.

## Composing it

It renders `position: fixed`, docked to a corner by the stylesheet, so it
does not need a positioned parent and can be mounted near the root of the
page. Nothing about it assumes a particular ancestor.

## When not to use it

Anywhere the law being satisfied requires blocking access until an answer is
given. This component is non-modal on purpose - no backdrop, no focus trap,
the page stays live - which is correct for "may I measure this" and wrong for
a gate the visitor cannot get past.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Render the bar. Keep it `false` once an answer has been recorded. |
| `children` | `ReactNode` | - | The question, e.g. what is measured and why. Plain content, no chrome. |
| `onAccept` | `() => void` | - | Pressed "yes". The host records the answer and starts measuring. |
| `onDecline` | `() => void` | - | Pressed "no". The host records the answer and stays dark. |
| `label?` | `string` | `"Privacy"` | Accessible name for the region and the default heading of the bar. |
| `acceptLabel?` | `string` | `"Allow"` |  |
| `declineLabel?` | `string` | `"Decline"` |  |

<!-- /generated:api -->

## Notes

`open` is the only state this component has. There is no internal "already
answered" flag - flipping `open` back to `true` after `onAccept` or
`onDecline` fired brings the bar straight back, so the host is responsible
for keeping it closed once an answer is recorded.

`acceptLabel` and `declineLabel` change the text, not the size: the
stylesheet renders both buttons at equal weight regardless of label length,
because the point of the component is that declining costs the same click as
accepting.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="consent" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useState } from "react";
import { Consent } from "@sushindustries/ui";
import { startAnalytics } from "./analytics";

export function RootLayout({ children }: { children: React.ReactNode }) {
	const [status, setStatus] = useState<"pending" | "granted" | "denied">(
		"pending",
	);

	return (
		<>
			{children}
			<Consent
				open={status === "pending"}
				onAccept={() => {
					setStatus("granted");
					startAnalytics();
				}}
				onDecline={() => setStatus("denied")}
			>
				I measure page views to see what is worth writing more of. Nothing
				personal, nothing sold.
			</Consent>
		</>
	);
}
```

## What this example is not

Not a working consent system on its own. It has no storage, so a real
`startAnalytics` call would still need to run this same check on every load -
read the stored answer first, and only render `Consent` at all when none
exists yet.
