Skip to content
Back to skills

Web Consent

ASecurity

How to use @owlmeans/web-consent — the React cookie-consent dialog, its re-open button, the generated cookie-policy page and the useConsent hooks, plus the Tailwind @source line every consumer must add. Auto-invoked when mounting a consent dialog, rendering a cookie policy, gating a feature on a consent category, or importing CookieConsent, CookiePolicy or useConsent.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 22, 2026
developmentgoshellreactnodeawsgit

Works with

  • cli

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill web-consent --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Web Consent?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Web Consent
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-web-consent-common/badge)](https://www.skillsdirectory.com/skills/owlmeans-web-consent-common)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: web-consent
description: How to use @owlmeans/web-consent — the React cookie-consent dialog, its re-open button, the generated cookie-policy page and the useConsent hooks, plus the Tailwind @source line every consumer must add. Auto-invoked when mounting a consent dialog, rendering a cookie policy, gating a feature on a consent category, or importing CookieConsent, CookiePolicy or useConsent.
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->

# @owlmeans/web-consent

**Layer:** Web (React)
**Install:** `"@owlmeans/web-consent": "^0.1.18-rc.43"` in `dependencies`

The browser components of the consent set. The model — categories, storage, migration, the store,
Consent Mode signalling — is `@owlmeans/consent`, and this package re-exports a **named selection**
of it (listed under Key Exports) so an application usually has one import. Four public
`@owlmeans/consent` exports are deliberately not in that list — `makeConsentStore`,
`CONSENT_SETUP_FLAG`, `CONSENT_SIGNAL_DEFAULTS` and the `ConsentListener` type —
so a caller that needs one of them imports it from `@owlmeans/consent` directly. **Read the
`consent` skill for the model; read this one for the components.**

## Deliberately not a shadcn package

It uses **no shadcn primitive and no `@` alias**, and owns a four-line `cn` of its own. The `@`
contract exists so a consumer's THEME and primitives win — `cn` has neither in it, and requiring an
alias for it would mean adopting an OwlMeans UI contract before you may render a consent notice.
One of the surfaces that needs this dialog most is a static site with its own component library and
no such alias, where that would simply fail to build.

For the same reason it is not part of `@owlmeans/web-panel`: that would drag ~30 packages onto a
cookie notice. `@owlmeans/web-panel/consent` is the thin binding to OwlMeans i18n on top of these
components — see below.

## Key Exports

| Export | Description |
|--------|-------------|
| `CookieConsent` | The preferences dialog **and** the persistent re-open button |
| `ConsentMenuWidget` | A plain row a HOST'S OWN menu renders to reopen the dialog, in place of (or beside) `CookieConsent`'s own floating button — see below |
| `CookiePolicy` | The cookie-policy page, generated from the configuration in force |
| `ConsentToggle` | One category row — locked and labelled when the category is required |
| `useConsent(opts?)` | This document's consent state and the actions over it (`UseConsentModel`) — **it also initialises the store on mount**, see below |
| `useConsentCategory(key)` | Whether one category is granted, for a component gating a single thing |
| `CookieConsentProps` / `CookiePolicyProps` / `ConsentLink` | The component props |
| Re-exports from `@owlmeans/consent` | The complete list: `consentStore`; `consentStorageHelper` (`readConsent`, `writeConsent`, `clearConsent`, `migrateConsent`); `consentModeHelper` (`applyConsent`, `pushConsentDefaults`, `consentBootstrapScript`, `consentDefaults`, `consentUpdate`, `gtagConsent`, `trackingGranted`, `consentGateScript`); `consentI18nHelper` (`defaultConsentTranslate`, `interpolate`, `normalizeLocale`); `DEFAULT_CONSENT_CATEGORIES`, `DEFAULT_CONSENT_MESSAGES`, `CONSENT_KEY`, `CONSENT_COOKIE_DAYS`, `CONSENT_SCHEMA_VERSION`, `CONSENT_LOCALES`, `CONSENT_ESSENTIAL` / `CONSENT_ANALYTICS` / `CONSENT_MARKETING`, the plugin seam (`consentPluginHelper`: `registerConsentPlugin`, `consentPlugins`, `decorateConsentUrl`, `consentDomains`, `adoptConsent`, `startConsentPlugins`, `adoptConsentLanguage`), the linker (`consentLinkHelper`: `consentLinker`, `encodeConsentLink`, `decodeConsentLink`, `stripConsentLinkParam`, `consentLinkerScript`, `writeConsentLanguage`; `CONSENT_LANGUAGE_KEY`, `CONSENT_EVENT`, `CONSENT_LINK_PARAM`, `CONSENT_LINK_MAX_AGE`, `CONSENT_LINK_SKEW`), and the types `ConsentCategory`, `ConsentOptions`, `ConsentReason`, `ConsentRecord`, `ConsentService`, `ConsentSignal`, `ConsentState`, `ConsentStore`, `ConsentLocale`, `ConsentLinkerOptions`, `ConsentLinkerLanguage`, `ConsentPlugin`, `ConsentLinkPayload` and the five helper interfaces |

## Mounting the dialog

`CookieConsent` is mounted **once**, at the application root or in the layout. It opens itself when
no decision is stored, renders nothing but the re-open button once one is, and needs no state from
the caller:

```tsx
import { CookieConsent } from '@owlmeans/web-consent'

<CookieConsent
  policyHref="/legal/cookies"
  links={[{ href: '/legal/privacy', labelKey: 'privacy', defaultLabel: 'Privacy Policy' }]}
/>
```

- **The category set the dialog renders is the set it saves.** Pass `categories` here and pass the
  same set to whatever stamps the head snippet, or the two disagree about what was asked. The same
  goes for `storageKey`, `cookieDays` and `cookieDomain`.
- `locale` picks the packaged language; leave it unset and English is used, so an app with a
  language of its own passes it (or mounts `PanelCookieConsent`, which does). A region tag is
  reduced to its base (`pl-PL` → `pl`), and anything outside `CONSENT_LOCALES` falls back to English.
- `policyHref` is a plain string, so an app-resolved path, a framework route and a raw href all
  work — the component must not know how its host does routing.
- `noReopenButton` hides the floating button for an app that offers a footer link instead; that link
  calls `consentStore.open('reopen')`.
- **The re-open button is a bare icon in the very corner (`bottom-1 left-1`), not a card.** No
  filled background, no border, no shadow, no hover-scale — `bg-transparent`, dimmed
  (`opacity-70`) at rest and picked out on hover/focus — because it sits on every page of a site
  for as long as a visitor stays and must read as a small fixture rather than compete with the
  page's own controls. The pictogram is 20px (`h-5 w-5`) inside an invisible 44px hit area
  (`h-11 w-11`). `[data-consent-reopen]` is what any test or CSS override keys on.
- **`linker` (`ConsentLinkerOptions`)** turns on cross-domain consent (see the `consent` skill's
  plugin-seam section): `<CookieConsent linker={{ domains: ['owlmeans.com', 'owlmeans.pl'] }} />`
  passes it straight to `useConsent`, which registers `consentLinkHelper.consentLinker` on mount. With it set and
  more than just the current host to disclose, a domain line renders right after the description
  (`[data-consent-domains]`, added to the dialog's `aria-describedby`) — computed DIRECTLY from
  `linker.domains` plus the current host, not through the plugin registry, so it never depends on
  registration having finished yet. Omit `linker` and nothing here changes at all. The same object
  carries `language` (`{}` to send the page's language, `{ supported }` to adopt one — the `consent`
  skill's language section): the dialog only forwards it to the store and discloses nothing extra.
- **The interface language is not a category.** The default set renders the required row and the
  analytics and marketing toggles only; the language is strictly necessary storage that no toggle
  governs (the `consent` skill). The built-in bundle (8 languages) describes it in the required row.

## The look — flat, from the host's tokens

The dialog is the first thing every new visitor of a generated app sees, so it follows the flat
rule those apps are held to: **no gradient, shadow, glow, glass or `backdrop-filter`** anywhere in
it. Everything is a theme token, so light and dark follow the host:

| Part | Classes |
|---|---|
| Overlay | `bg-black/70`, flat — no blur |
| Card | `rounded-3xl border border-border bg-background text-foreground`, no shadow or ring |
| Icon | `rounded-full bg-muted text-primary` |
| Category row | `rounded-2xl border border-border`; *Required* is plain uppercase muted text, never a badge |
| Switch | `bg-primary` on / `bg-muted-foreground/40` off, white knob; 44px hit area around the 24×44 track |
| Accept | the one accent pill — `rounded-full bg-primary text-primary-foreground min-h-11` |
| Save, policy *Manage preferences* | outlined pill — `rounded-full border-[1.5px] border-foreground bg-transparent`, `hover:bg-muted` |
| Links | muted, underlined at rest, `min-h-11` |

Every control shows a 3px `outline-ring` focus outline 3px off the element (`outline-3
outline-offset-3`; the switch draws it on its track through `peer-focus-visible`). An outline, not
a ring, so a host that zeroes shadows keeps it. Never reintroduce a decorative class here — the
package tests fail on `bg-gradient`, `backdrop-`, `blur`, `shadow` or `ring-primary` in the dialog.

**Each switch is named by reference.** The `<label>` around a switch's checkbox holds only the
drawn track, and the category's words sit in a sibling column it cannot reach, so the checkbox
carries `aria-labelledby="<id>-label"` (the category label) and `aria-describedby` pointing at
`<id>-required` and `<id>-desc`. Drop those and axe reports three unnamed checkboxes (`label`,
critical). A required category stays `disabled` and keeps its name.

`tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open dialog in both schemes, the
re-open button, and the policy page with services, and fails on any serious or critical
violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
sources with a generated app's neutral tokens — contrast cannot be judged on the unstyled harness
the other specs need.
- `silent` skips every `dataLayer` and global write. It is for tests and for an app that runs no
  tags at all.
- The draft is **re-seeded from storage every time the dialog opens**, not from the last render — a
  visitor reopening preferences must see the answer they gave.

**When the dialog was raised by something waiting on it** — `reason === 'login'`, which the sign-in
precondition in `@owlmeans/client-iam` raises — it says so and relabels the primary action *Accept
& continue*. That is what makes the interruption legible instead of looking like the page asking
twice. Word that path as an acknowledgement, never as "you must consent to essential cookies": a
required category is disclosure, not a question.

## A menu row for a host that already has one

`ConsentMenuWidget` renders one row — icon, translated label, `onClick` reopening the dialog
(`consentStore.open('reopen')` by default, or a caller's own `onSelect`) — for a host whose own
navigation already carries a settings menu and would rather offer cookie preferences there than as
a second floating button. It calls `consentStore.open` directly, never `useConsent()`, for the same
init-on-mount reason as everywhere else in this document.

**It carries no presence signalling of its own.** A host that wants `CookieConsent`'s floating
button to hide while this row is reachable needs to know that itself, from something that stays
mounted for as long as the row is reachable — never from the row's own mount. A row placed inside a
host menu's lazily-rendered content (Radix's `DropdownMenuContent`, and most headless menu
primitives, only mount their children while the menu is actually OPEN) reports "present" for a
fraction of the time the menu itself is on screen, and the floating button reappears the instant
the menu closes. `@owlmeans/web-panel/consent`'s `useConsentMenuPresence()` is the fix for a
context-aware host: call it from the menu's own always-mounted shell component, not from inside the
row. A context-free host (an Astro island with its own menu) owns that judgement itself.

## Reading the decision

```tsx
import { consentStore, useConsent, useConsentCategory } from '@owlmeans/web-consent'

const consent = useConsent()          // { record, open, reason, granted, save, acceptAll, openDialog, close }
const analytics = useConsentCategory('analytics')   // one category, for a component gating one thing
if (consentStore.granted('analytics')) { /* outside React — a click handler, a service */ }
```

Both hooks subscribe through `useSyncExternalStore` over the module-singleton store, because consent
is a property of the DOCUMENT rather than of a component tree: the dialog, the re-open button, the
policy page and whatever an app gates on it all read one value, and local copies would disagree the
moment one of them saved. During server rendering the snapshot is "no record, closed", so the dialog
never flashes into static HTML before hydration corrects it.

**`useConsent` is not a pure reader — it runs `consentStore.init(opts)` on mount.** Two consequences
a component that only wanted to read has to plan for:

- **It opens the dialog.** `init` reads storage, and with no stored record it publishes
  `open: true, reason: 'initial'`. So a `useConsent()` in a card that merely wanted `granted('analytics')`
  gates the page for a first-time visitor. Read a single category with `useConsentCategory(key)` —
  that hook subscribes and does **not** init — or `consentStore.granted(key)` outside React.
- **The first `useConsent` to mount fixes the Consent Mode defaults.** `init` calls
  `consentModeHelper.pushConsentDefaults`, which is idempotent through the `CONSENT_SETUP_FLAG` window flag: whoever
  gets there first declares the `consent/default` signals from *its* categories, and every later
  call returns immediately. A bare `useConsent()` mounting before `CookieConsent` therefore declares
  `DEFAULT_CONSENT_CATEGORIES`' signals and the app's own `categories` never declare theirs — which
  silently breaks the parity rule above. Pass the app's `opts` wherever `useConsent` is called, or
  do not call it outside the dialog.

`opts` are read on the mounting pass only (the effect's dependency list is empty), so changing them
in a later render has no effect on that mount.

## The policy page

`CookiePolicy` states only what the widget provably does — the categories in force, the storage key,
the dual storage, the retention — all read from the same configuration the dialog renders. That is
why it is generated rather than written: a hand-written policy drifts the first time a category
changes, and nobody notices because nobody reads it until it matters.

```tsx
import { googleTagHelper } from '@owlmeans/web-gtm'

<CookiePolicy
  operator="Example Sp. z o.o." privacyHref="/legal/privacy" termsHref="/legal/terms"
  services={googleTag !== '' ? googleTagHelper.googleTagServices(googleTag) : undefined}
/>
```

Everything OwlMeans cannot assert on the operator's behalf — who the controller is, the lawful
basis, how to exercise rights — is deferred to those two links. It also renders a *Manage
preferences* control, so the policy page is a way back into the decision.

**`services`** (`ConsentService[]`, see the `consent` skill) discloses WHO receives data: each
service is listed inside the item of the category whose `key` it names — name, provider, purpose,
cookie names in `<code>`, and a link to the provider's privacy policy (new tab). A service whose
category is not among `categories` goes into a trailing *Other services* item rather than being
dropped. Without `services` the page renders exactly as it always has.

The structure stays one `h1` and lists: categories are `li`s, a category's services are a nested
`ul` named by `aria-label` (*Services — ‹category label›*), each service a `li` holding a `dl`.
Never add headings for categories or services — they would skip a level under the `h1`. Test and
CSS hooks: `[data-cookie-policy-category="<key>"]`, `[data-cookie-policy-services]`,
`[data-cookie-policy-service]`, `[data-cookie-policy-other]`.

**`linker`**, same shape as `CookieConsent`'s, adds a plain `[data-cookie-policy-domains]` LIST
(never a heading) right after the storage-key paragraph — the current host plus every domain
`linker.domains` names, computed the same direct way the dialog computes it. Omitted or with
nothing beyond the current host to disclose, nothing here renders.

The service labels (`policyProvider`, `policyPurpose`, `policyCookies`, `policyServicesOf`,
`policyServicePrivacy`, `policyOtherServices`, `policyOtherServicesDesc`) go through `translate`
with English defaults and are in the packaged bundle for every locale; the service's own `name`,
`provider` and `purpose` are data and render as given.

## Tailwind — one `@source` line, pointing at `src`

The components emit Tailwind classes that exist nowhere in the consumer's own sources, and
Tailwind's scanner reads the CSS root plus `@source` directives only, excluding `node_modules`. Add
the line to the app's Tailwind entry:

```css
@import "tailwindcss";

@source "<relative path to node_modules>/@owlmeans/web-consent/src";
```

**Point it at `src`, never at `build`.** The scanner applies the `.gitignore` of whatever repository
a path resolves into, and under a linked workspace the `node_modules` entry is a symlink into a
monorepo where every package's build output is ignored. A `build` source there scans **zero files
and reports nothing**: the build succeeds, the CSS is emitted, and the dialog renders *half*-styled
— the utilities the app happens to use elsewhere still exist while the ones only this package asks
for (`max-w-lg`, `bg-black/70`, `z-[999998]`) do not. What reaches the screen is a full-width,
backdrop-less dialog with the page bleeding through, and nothing in the app's sources looks wrong.
`src` is tracked and ships in the published tarball, so one path serves both a linked checkout and
an npm install.

The failure is silent, so verify rather than assume — grep the emitted stylesheet for a class only
this package uses:

```sh
grep -c 'max-w-lg' dist/assets/*.css
```

A `*/` inside a CSS comment ends it early, so a comment above an `@source` that spells out a glob
closes the comment at that star-slash and turns the next `@source` into an invalid declaration.
Describe a glob in words, or keep it out of the comment.

## i18n

Given no `translate` prop, the components resolve every string through the packaged bundle for
`locale` — every language in `CONSENT_LOCALES` (the framework's seven plus French). The bundle
lives in `@owlmeans/consent` (`src/i18n/<locale>.json`); a new key goes into every one of them,
and that package's tests fail on a missing key or a dropped `{{placeholder}}` in any locale.
This package's `tests/bundle.spec.ts` reads every `t('…')` literal from the components, so a new
string without an English bundle entry fails there. A JSON-only edit is not re-emitted by an
incremental `tsc -b` — rebuild `@owlmeans/consent` with `tsc -b --force`, or consumers keep the old
bundle from `build/i18n`. **Once a `translate` prop is given,
the packaged bundle is not consulted at all**, so a wrapper that forwards a framework resolver alone
renders the English default for every key the application has not overridden, in every language. The
resolver must fall through to `consentI18nHelper.defaultConsentTranslate(locale)` for the default — which is exactly
what `@owlmeans/web-panel/consent` does, and why an app inside the panel family mounts
`PanelCookieConsent` / `PanelCookiePolicy` rather than these components directly.

`ConsentToggle` is exported for a host that builds its own dialog body; the wording it shows is the
caller's, already resolved.

## Depends On

- `@owlmeans/consent` — the model; the named selection above is re-exported from this package's root
- Peers (app-provided): `react`, `tailwindcss`, `tailwind-merge`, `clsx`, `lucide-react`

## Related

- `consent` — the model: categories, `globalVar`, storage and migration, Consent Mode v2, the
  ordering rule. Read it before changing anything a category means
- `web-gtm` — the head snippet that carries a stored decision to a Google tag, and
  `googleTagHelper.googleTagServices(id)` for this page's `services`
- `astro` — stamping that snippet from a static site's layout
- `login-methods` / `login-plugins` — the sign-in precondition that raises this dialog with
  `reason: 'login'`
- `web-panel` — its `./consent` subpath, the OwlMeans-i18n binding of these components

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…