Styling Architecture

How NDPA Toolkit components are styled in v3.4+: BEM class names, --ndpr-* tokens, and the /unstyled escape hatch.

Overview

As of v3.4.0, every NDPA Toolkit component ships with semantic BEM-style class names backed by a real stylesheet. There is no Tailwind dependency — the toolkit works in any host (Next.js, Vite, CRA, Remix, raw HTML) provided you import the stylesheet once. Components remain themable through CSS custom properties and replaceable via the classNames slot map or the /unstyled entry.

If you're upgrading from 3.3.x

The previous releases shipped components with Tailwind utility classes baked into JSX strings, which silently produced unstyled markup unless your Tailwind content config scanned node_modules. That coupling is gone — see the upgrade guide.

1. Import the stylesheet

Add this one line in your app entry. Components render with full default styling immediately afterwards.

// Next.js App Router — app/layout.tsx
// Vite / CRA — src/main.tsx
import "@tantainnovative/ndpr-toolkit/styles";

PostCSS, Tailwind v4, Sass, and Bun all resolve this through the package's style / sass / import / require conditional exports. From a CSS file you can also do @import:

/* globals.css */
@import "@tantainnovative/ndpr-toolkit/styles";

2. Class naming convention

All toolkit class names follow a consistent BEM-style pattern:

.ndpr-{component}                  /* component root */
.ndpr-{component}__{element}       /* child part */
.ndpr-{component}--{modifier}      /* variant or state */

For example, the consent banner exposes:

.ndpr-consent-banner               /* root */
.ndpr-consent-banner--card         /* variant: bounded floating card */
.ndpr-consent-banner--bottom       /* placement modifier */
.ndpr-consent-banner__container    /* inner content wrapper */
.ndpr-consent-banner__title        /* heading */
.ndpr-consent-banner__buttons      /* action button row */
.ndpr-consent-banner__button       /* shared button base */
.ndpr-consent-banner__button--primary
.ndpr-consent-banner__button--secondary

You can target these directly from your own CSS without any toolkit utilities. Hash-suffixed internal chunk class names (the v3.3-era _styles_h7c2k pattern) are gone.

3. Data attributes for stable hooks

Every component root carries a data-ndpr-componentattribute. Stateful variants (mode, variant, position) get their own data attributes too. Use these when you want a stable consumer-side selector that doesn't depend on the internal class names.

/* Stable across releases — internal class names may evolve */
[data-ndpr-component="consent-banner"][data-ndpr-variant="card"] {
  border-radius: 1rem;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}

[data-ndpr-component="policy-page"] {
  font-family: var(--my-prose-font);
}

4. Theming via CSS custom properties

The stylesheet is opinionated but token-driven. Override any --ndpr-* property at :root, on a per-section parent, or scoped to [data-theme="dark"].

:root {
  /* Brand */
  --ndpr-primary: 22 163 74;            /* RGB triplet — green-600 */
  --ndpr-primary-hover: 21 128 61;      /* green-700 */
  --ndpr-primary-foreground: 255 255 255;

  /* Neutrals */
  --ndpr-background: 255 255 255;
  --ndpr-surface: 249 250 251;
  --ndpr-foreground: 17 24 39;
  --ndpr-muted: 243 244 246;
  --ndpr-muted-foreground: 107 114 128;
  --ndpr-border: 229 231 235;

  /* Shape */
  --ndpr-radius: 1rem;
  --ndpr-font-sans: "Inter", system-ui, sans-serif;
}

Token values use RGB triplets without commas — the toolkit applies them via rgb(var(--ndpr-primary)) internally so you can opacify them as needed (rgb(var(--ndpr-primary) / 0.1)).

5. Dark mode

The toolkit supports three dark-mode strategies, mix and match as your host site requires:

StrategyTriggerUse when
prefers-color-schemeOS-level settingYour host site already follows OS preference
data-theme="dark"Attribute on any ancestorExplicit user toggle (recommended)
.dark classClass on any ancestorTailwind's default class strategy

For <PolicyPage /> specifically, dark mode is opt-in via the options.theme prop because Shadow DOM does not isolate prefers-color-scheme. See Privacy Policy Generator for details.

6. Per-instance class overrides

Every component accepts a classNames slot map. Pass your own class string for any named slot — the default .ndpr-* class is replaced (not appended) for that slot.

<ConsentBanner
  options={options}
  onSave={handleSave}
  classNames={{
    root: "fixed bottom-0 inset-x-0 my-banner",
    title: "text-2xl font-display",
    acceptButton: "btn btn-primary",
    rejectButton: "btn btn-ghost",
  }}
/>

7. The /unstyled escape hatch

When you want to bring your own design system entirely (Tailwind, Mantine, Chakra, raw CSS — your choice), import from /unstyled instead of the default entry. Every component has unstyled defaulted to true, stripping all .ndpr-* classes.

import { ConsentBanner, ConsentManager, DSRRequestForm }
  from '@tantainnovative/ndpr-toolkit/unstyled';

// No default classes — your CSS applies unfiltered. ARIA, focus
// management, and data-ndpr-* attributes are preserved (they're
// part of the contract, not styling).
<ConsentBanner
  options={options}
  onSave={handleSave}
  classNames={{ root: 'my-banner', acceptButton: 'btn-primary' }}
/>

You can also opt back into default styling per-instance by passing unstyled={false} — useful when migrating screen-by-screen.

Next steps