Skip to main content

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 same exemptPathPrefixes (see First paint).
  • The provider renders <ConsentBanner /> and <CookieSettingsButton /> automatically (controlled by showFloatingButton - default true).
  • 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​

SymbolKindPurpose
ConsentProviderComponentWraps the app, loads/persists state, renders banner + floating button.
useConsent()HookReturns hasConsented, isAllowed(category), consent, openPreferences, isExempt. (openPreferences is the context's openBanner.)
useConsentContext()HookThe full context value, including acceptAll / acceptEssentialOnly / closeBanner. useConsent() is the narrowed, preferred surface.
useCookiePreferences, useCookieStatusHookPreferences-modal state and per-category status.
useCookieDetector, useStorageDetector, useBrowserDataDetectorHookReactive wrappers over the detection utils below.
ConsentPrePaintScriptComponent (server)Inline <head> script that decides before the first paint whether the banner shows.
buildConsentPrePaintScript(exemptPathPrefixes)UtilThe script source ConsentPrePaintScript renders, for a custom <head>.
ConsentBanner, CookieSettingsButton, CookieStatusIndicatorComponentStandalone UI pieces if you need them outside the provider's auto-render.
ConditionalScript, ConditionalContent, EssentialScriptComponentOnly mount <script>-like content when a category is allowed.
GoogleAnalytics, FacebookPixel, Hotjar, Matomo, PlausibleComponentPre-wired analytics integrations that respect consent.
defaultTranslationsData{ en, de } default copy.
getStoredConsent, setStoredConsent, clearStoredConsentUtilLow-level localStorage access.
getDetectedCookies, getCookiesByCategory, getCookiesByService, hasServiceCookies, getCookieSummaryUtilRuntime cookie detection (drives the preferences modal).
getDetectedStorage, getDetectedLocalStorage, getDetectedSessionStorage, getStorageByCategory, getStorageByType, getStorageByService, hasServiceStorage, getStorageSummaryUtilSame for localStorage / sessionStorage.

Integration status​

The library is mounted in three consumer apps:

AppMounted inappNameexemptPathPrefixesPolicy URL
apps/websrc/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/idsrc/app/layout.tsx"ID"none${WEB_URL}/legal/cookies
apps/statssrc/app/layout.tsx"Stats"nonehttps://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/web wraps every public tree in PublicMediaPolicy (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) and src/components/legal/legal-shell.tsx. It must sit below a ConsentProvider.
  • The policy sets AvatarImagePolicy from @lumio/ui with resolveSrc={proxiedImageUrl}. Every AvatarImage and ChannelAvatar below 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) and useGifsAllowed() (GIFs). Outside a policy useImageSrc() returns the URL unchanged and GIFs are allowed. A null from useImageSrc() means "render the placeholder", never the raw URL.
  • apps/stats mounts ProxiedImagePolicy once in src/app/layout.tsx and rewrites emotes (EmoteImage) and panel artwork (proxiedSlides in channel-panels.tsx) on the server, so the HTML carries no CDN URL. top-gifs.tsx keeps its useConsent() 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, because usePathname() also resolves during server rendering.
  • The pre-paint script sets nothing, provided it got the same list.
  • useConsent().isExempt === true.
  • isAllowed("essential") returns true; all other categories return false.
  • acceptAll / acceptEssentialOnly / openPreferences / openBanner / closeBanner are 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:

  1. 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.
  2. <ConsentPrePaintScript /> runs inline in <head>. When no valid choice is stored and the path is not exempt, it sets data-consent="needed" on <html>. Only then does the CSS show the banner (and lock page scrolling).
  3. 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 as setStoredConsent() and sets data-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.
  4. On hydration ConsentProvider reads the stored choice, removes the data-consent attribute and drops data-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.

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.