Cookies Consent Library
The @lumio/cookies-consent shared package provides a GDPR-compliant cookie-consent UX for Lumio's external frontend apps (apps/web, apps/id and apps/stats).
It wraps a provider, banner, preferences modal, floating settings button, and detection hooks, with one additive feature (exemptPathPrefixes) to silence the banner on OBS overlay routes where a consent dialog would be inappropriate.
When to use
- Wrap your app's root layout with
<ConsentProvider>. - Render
<ConsentPrePaintScript />inside that layout's<head>, with the sameexemptPathPrefixes(see First paint). - The provider renders
<ConsentBanner />and<CookieSettingsButton />automatically (controlled byshowFloatingButton- defaulttrue). - Use
useConsent()from any client component to gate loading of non-essential scripts / cookies.
Consumer apps must mount <TooltipProvider />
The CookieSettingsButton floating tooltip uses Lumio's shared <TooltipProvider /> (from @lumio/ui) which reads data-tooltip / data-tooltip-pos attributes. Mount the provider once at your app's root layout.
data-tooltip-pos (top | bottom | left | right, default top) is a preference, not a hard constraint: if the tooltip would clip past the viewport on the requested side it flips to the opposite side, and its cross-axis position is clamped to the viewport edge instead of being cut off. This keeps tooltips readable when the anchor sits in the top row of a popout or hard against a viewport edge.
Public API
| Symbol | Kind | Purpose |
|---|---|---|
ConsentProvider | Component | Wraps the app, loads/persists state, renders banner + floating button. |
useConsent() | Hook | Returns hasConsented, isAllowed(category), consent, openPreferences, isExempt. (openPreferences is the context's openBanner.) |
useConsentContext() | Hook | The full context value, including acceptAll / acceptEssentialOnly / closeBanner. useConsent() is the narrowed, preferred surface. |
useCookiePreferences, useCookieStatus | Hook | Preferences-modal state and per-category status. |
useCookieDetector, useStorageDetector, useBrowserDataDetector | Hook | Reactive wrappers over the detection utils below. |
ConsentPrePaintScript | Component (server) | Inline <head> script that decides before the first paint whether the banner shows. |
buildConsentPrePaintScript(exemptPathPrefixes) | Util | The script source ConsentPrePaintScript renders, for a custom <head>. |
ConsentBanner, CookieSettingsButton, CookieStatusIndicator | Component | Standalone UI pieces if you need them outside the provider's auto-render. |
ConditionalScript, ConditionalContent, EssentialScript | Component | Only mount <script>-like content when a category is allowed. |
GoogleAnalytics, FacebookPixel, Hotjar, Matomo, Plausible | Component | Pre-wired analytics integrations that respect consent. |
defaultTranslations | Data | { en, de } default copy. |
getStoredConsent, setStoredConsent, clearStoredConsent | Util | Low-level localStorage access. |
getDetectedCookies, getCookiesByCategory, getCookiesByService, hasServiceCookies, getCookieSummary | Util | Runtime cookie detection (drives the preferences modal). |
getDetectedStorage, getDetectedLocalStorage, getDetectedSessionStorage, getStorageByCategory, getStorageByType, getStorageByService, hasServiceStorage, getStorageSummary | Util | Same for localStorage / sessionStorage. |
Integration status
The library is mounted in three consumer apps:
| App | Mounted in | appName | exemptPathPrefixes | Policy URL |
|---|---|---|---|---|
apps/web | src/app/(main)/layout.tsx | "Web" | ["/overlay", "/popout", "/api", "/dashboard", "/account", "/hub"] | /legal/cookies (relative) |
apps/web (public share page) | src/app/(public)/layout.tsx | "Web" | none | /legal/cookies (relative) |
apps/id | src/app/layout.tsx | "ID" | none | ${WEB_URL}/legal/cookies |
apps/stats | src/app/layout.tsx | "Stats" | none | https://lumio.vision/legal/cookies |
Each of these layouts also renders <ConsentPrePaintScript /> in its <head>. apps/web keeps its exempt list in one CONSENT_EXEMPT_PATHS constant and passes it to both the script and the provider. Each app's globals.css lists shared/cookies-consent/src as a Tailwind @source, otherwise the banner's utility classes are not generated.
Each app mounts <TooltipProvider /> next to <ConsentProvider> inside NextIntlClientProvider. The provider renders the banner and floating settings button internally - consumers do NOT pass them as children. apps/web ((main) layout) and apps/id additionally nest <SentryConsentSync /> inside the provider so Sentry's capture follows the consent state (Error tracking).
Third-party images on public pages. Public trees of apps/web (share page, marketing, legal) wrap their content in <PublicMediaPolicy> (apps/web/src/components/third-party-media-gate.tsx); Lumio Stats mounts ProxiedImagePolicy once in its root layout. Avatars, emotes, badges and Bits graphics then load through the same-origin image proxy and need no consent. Only GIPHY GIFs still wait for Functional consent (useGifsAllowed() in web, useConsent() in Stats top-gifs.tsx). See Third-party images on public pages.
The webapp exempts the whole authenticated surface (/dashboard, /account, /hub) in addition to the embed routes, so the banner only appears on the public marketing and legal pages.
apps/admin does NOT integrate the consent library. Admin is an internal, auth-gated tool for the zaflun team - no external end-users reach it, so no GDPR consent prompt is required.
Bot apps and apps/docs do not integrate the library either (server-only / Docusaurus).
Third-party images on public pages
Platform avatars (Twitch, Google, Discord, Kick), chat emotes, badges, GIPHY GIFs and Bits graphics live on the provider's CDN. Loading one straight from there sends the visitor's IP address and user agent to that provider. Signed-in dashboard and popout pages load them directly, because they are contract-based. Pages reachable without login do not:
- Avatars, emotes, badges, panel artwork, Bits / KICKs graphics load through the same-origin image proxy (
/img/<provider>/…). No third party receives a request from the visitor, so there is no consent gate for them. - GIPHY GIFs are not proxied and load only after Functional consent. Before that, the page mounts no
<img>pointing at GIPHY. Hiding a mounted image would still send the request.
How the apps wire it:
apps/webwraps every public tree inPublicMediaPolicy(src/components/third-party-media-gate.tsx):src/app/(public)/layout.tsx(share pages),src/app/(main)/(marketing)/layout.tsx(ideas board, invite pages and the other marketing pages) andsrc/components/legal/legal-shell.tsx. It must sit below aConsentProvider.- The policy sets
AvatarImagePolicyfrom@lumio/uiwithresolveSrc={proxiedImageUrl}. EveryAvatarImageandChannelAvatarbelow it loads the proxied URL, or shows its initials when the proxy does not cover the URL. Avatars need no per-call code. - Other image leaves call
useImageSrc()/useImageSrcSet()(emotes, badges, Bits, the hover card) anduseGifsAllowed()(GIFs). Outside a policyuseImageSrc()returns the URL unchanged and GIFs are allowed. AnullfromuseImageSrc()means "render the placeholder", never the raw URL. apps/statsmountsProxiedImagePolicyonce insrc/app/layout.tsxand rewrites emotes (EmoteImage) and panel artwork (proxiedSlidesinchannel-panels.tsx) on the server, so the HTML carries no CDN URL.top-gifs.tsxkeeps itsuseConsent()gate.- A new public page that renders these components must sit below the policy.
exemptPathPrefixes
Pass this prop to the provider to suppress the consent UI on specific pathname prefixes (e.g. OBS browser-source routes at /overlay/* where a banner would break the embed).
On matching paths:
<ConsentBanner />and<CookieSettingsButton />do not render - not even in the server HTML, becauseusePathname()also resolves during server rendering.- The pre-paint script sets nothing, provided it got the same list.
useConsent().isExempt === true.isAllowed("essential")returnstrue; all other categories returnfalse.acceptAll / acceptEssentialOnly / openPreferences / openBanner / closeBannerare no-ops.
<ConsentProvider
translations={defaultTranslations[locale]}
policyUrl="/legal/cookies"
appName="Web"
exemptPathPrefixes={["/overlay", "/api"]}
footerLinks={{ privacyUrl: "/legal/privacy", imprintUrl: "/legal/imprint" }}
>
{children}
</ConsentProvider>
First paint (pre-paint script)
The visitor's choice lives in localStorage, which the server cannot read. So the banner is decided in the browser, before the first paint, without waiting for the app to hydrate:
- On every non-exempt page the provider renders the banner into the server HTML inside
<div class="lumio-consent-root" data-prepaint>. CSS (ConsentBanner.css) hides it. <ConsentPrePaintScript />runs inline in<head>. When no valid choice is stored and the path is not exempt, it setsdata-consent="needed"on<html>. Only then does the CSS show the banner (and lock page scrolling).- Until the app hydrates, the script also handles Accept all and Essential only: a tap on a
[data-consent-action]button stores the choice in the same format assetStoredConsent()and setsdata-consent="given", which hides the banner. The tap is not passed on to React, so it is not replayed after hydration. Customize needs React; until then it shows a pending state. - On hydration
ConsentProviderreads the stored choice, removes thedata-consentattribute and dropsdata-prepaint. From then on React alone decides whether the banner is open.
Result: a visitor without a choice sees the banner in the first paint; a visitor with a choice never sees it, not even for a frame. If the script does not run, the provider still opens the banner after hydration.
The Customize view (cookie and storage detection, the per-category tables) is code-split with React.lazy and loads when opened, behind a layout-matched skeleton.
Below the md breakpoint the backdrop is a plain dark dim. The backdrop blur is used from md up only, because a full-screen blur cost about 16 % of the frame rate on a throttled phone (Moto G Power emulation, 4× CPU: ~16.8 vs ~20.0 fps with animated content underneath).
The script is an inline script without a nonce, like the next-themes script. apps/web's app-level CSP is Report-Only with 'unsafe-inline' in script-src. A future enforcing CSP has to allow it by nonce or hash, together with the other inline scripts.
Adding a cookie
Edit shared/cookies-consent/src/utils/cookieDetector.ts. Append to the cookiePatterns array:
{ pattern: /^your_cookie_name$/, category: "functional", service: "Your Service" }
An optional devOnly: true marks a cookie that only appears in local development. A name matching no pattern falls through to category: "unknown".
Also add a matching entry to shared/cookies-consent/src/i18n/en.json (and de.json) under the appropriate category's cookies array so the preferences modal shows the human-friendly name + purpose + duration.
Storage key
Persistent state is stored under localStorage["lumio_cookie_consent"] as { version, ... }. getStoredConsent() returns ConsentState | null and clears the entry when the stored version does not match the library's current STORAGE_VERSION, or when the record is incomplete (hasConsented is not true, or categories is missing). A version bump therefore re-prompts every user. The pre-paint script applies the same rule (shared/cookies-consent/src/utils/prepaint.ts), so both always agree on whether the banner shows.
Categories
essential, functional, statistics, marketing (CookieCategory in shared/cookies-consent/src/types/consent.ts) - semantics match the spec at docs/superpowers/specs/2026-04-16-cookies-consent-library-design.md, with the app integration described in 2026-04-17-cookies-consent-integration-design.md.