consent-ui
v0.1.0
Published
A tiny, tree-shakeable React component library with CSS Modules and CSS variable theming.
Maintainers
Readme
consent-ui
A small, tree-shakeable React component library. Ships Button and Card, styled with CSS Modules and themed entirely through --consent-ui-* CSS custom properties.
- No CSS reset. The package never restyles your elements. Each component imports only its own scoped stylesheet.
- SSR and RSC safe. Verified against
react-dom/server; works in the Next.js App Router. - React is a peer dependency. It is never bundled.
- Tree-shakeable. ESM + CJS,
preserveModules, and per-component subpath exports.
A bundler is required. Components import their own CSS, so the package cannot be loaded by plain Node without a build step. Next.js, Vite, webpack, Remix, Parcel and friends all handle this out of the box.
Install
pnpm add consent-ui
# npm install consent-ui / yarn add consent-uiReact 18 or 19 is required as a peer dependency.
Usage
import { Button, Card } from 'consent-ui';
export function CookieBanner() {
return (
<Card
title="Cookie preferences"
description="We use cookies to improve the site."
footer={
<>
<Button variant="primary">Accept all</Button>
<Button variant="ghost">Reject all</Button>
</>
}
>
Choose which categories you allow. You can change this at any time.
</Card>
);
}Component CSS is imported by the components themselves — there is nothing extra to import for them to look right.
Individual imports
Every component also has its own entry point:
import { Button } from 'consent-ui/button';
import { Card } from 'consent-ui/card';How tree-shaking behaves
The package is built with preserveModules, so every component is its own module, and it declares "sideEffects": ["**/*.css"]. Unused component JavaScript is always removed, whichever import style you use.
Stylesheets differ slightly by bundler, because CSS imports are side effects:
| Bundler | import { Button } from 'consent-ui' | import { Button } from 'consent-ui/button' |
| ------------------------ | ------------------------------------- | -------------------------------------------- |
| Vite / Rollup | Only Button.css | Only Button.css |
| Next.js / webpack | All component CSS | Only Button.css |
Webpack collects CSS across the whole module graph before tree-shaking, so the barrel pulls in every component's stylesheet even though the unused JavaScript is dropped. If you use Next.js and want the smallest possible CSS, import from the subpaths. The difference is small — the full stylesheet for this library is a few kilobytes — but the option is there.
Next.js
Works out of the box in both the App Router and Pages Router — no transpilePackages needed.
// app/page.tsx — a Server Component
import { Card, Button } from 'consent-ui';
export default function Page() {
return <Card title="Cookies" footer={<Button>Accept all</Button>}>…</Card>;
}Button is marked 'use client' because it handles events; Card is not, so it renders as a Server Component. You can drop either into a server or client tree without thinking about it.
Class names are generated at build time and are identical on the server and client, so there are no hydration mismatches.
Theming
All styling is driven by CSS custom properties. Set them on any ancestor to retheme that subtree — you never need to override a class name:
.checkout {
--consent-ui-color-accent: #7c3aed;
--consent-ui-radius-md: 12px;
}Every component ships fallback values inline, so they look correct without any extra setup.
The token stylesheet (optional)
If you want to override tokens globally, import the base stylesheet once at the root of your app:
// app/layout.tsx or pages/_app.tsx
import 'consent-ui/tokens.css';This file only declares custom properties on :root — it contains no reset and no element selectors, so it cannot change how your own markup looks.
Once imported, you can opt into the bundled dark theme:
<html data-consent-ui-theme="dark">
<!-- or follow the OS setting -->
<html data-consent-ui-theme="auto">Available tokens
| Group | Tokens |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Surface | --consent-ui-color-surface, --consent-ui-color-surface-muted, --consent-ui-color-border |
| Text | --consent-ui-color-text, --consent-ui-color-text-muted, --consent-ui-color-text-on-accent |
| Intent | --consent-ui-color-accent{,-hover,-active}, --consent-ui-color-danger{,-hover,-active} |
| Focus | --consent-ui-color-focus-ring, --consent-ui-focus-ring-width, --consent-ui-focus-ring-offset |
| Radius | --consent-ui-radius-sm (4px), -md (8px), -lg (12px), -full |
| Spacing | --consent-ui-space-1 … --consent-ui-space-6 |
| Typography | --consent-ui-font-family, --consent-ui-font-size-{sm,md,lg,xl}, --consent-ui-font-weight-{regular,medium,bold}, --consent-ui-line-height |
| Elevation | --consent-ui-shadow-sm, --consent-ui-shadow-md |
| Motion | --consent-ui-transition-fast |
| Misc | --consent-ui-disabled-opacity |
Per-instance overrides
Because tokens cascade, you can theme a single component inline:
<Button style={{ '--consent-ui-color-accent': '#059669' } as React.CSSProperties}>
Accept
</Button>You can also pass className — it is merged with the component's own classes, never replaced.
API
Button
Extends all native <button> props.
| Prop | Type | Default | Description |
| -------------- | -------------------------------------------------- | ----------- | --------------------------------------------------------------- |
| variant | 'primary' \| 'secondary' \| 'ghost' \| 'danger' | 'primary' | Visual style. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | Height and horizontal padding. |
| fullWidth | boolean | false | Stretch to fill the container. |
| loading | boolean | false | Show a spinner and block clicks, while staying focusable. |
| loadingLabel | string | 'Loading' | Text announced while loading. |
| startIcon | ReactNode | — | Rendered before the label. |
| endIcon | ReactNode | — | Rendered after the label. |
type defaults to "button" so it never submits a form by accident. While loading, the button sets aria-busy and aria-disabled rather than the disabled attribute, so focus is not lost and screen readers announce the state change.
Card
Extends all native <div> props except title, which is widened to ReactNode.
| Prop | Type | Default | Description |
| -------------- | --------------------------------------------- | ------------ | ------------------------------------------------------ |
| variant | 'elevated' \| 'outlined' \| 'filled' | 'outlined' | Visual style of the surface. |
| padding | 'none' \| 'sm' \| 'md' \| 'lg' | 'md' | Inner spacing for all slots. |
| interactive | boolean | false | Hover and focus-within affordances. |
| title | ReactNode | — | Heading rendered above the content. |
| description | ReactNode | — | Supporting text under the title. |
| headingLevel | 1 \| 2 \| 3 \| 4 \| 5 \| 6 | 3 | Heading level for title, so it fits your page outline. |
| media | ReactNode | — | Full-bleed slot above the header. |
| footer | ReactNode | — | Slot at the bottom, typically actions. |
Both components forward refs and spread unknown props onto the underlying element.
Development
pnpm install
pnpm dev # Storybook on http://localhost:6006
pnpm test # Vitest + React Testing Library (incl. SSR tests)
pnpm typecheck
pnpm build # ESM + CJS + .d.ts/.d.cts into dist/
pnpm lint:package # publint + are-the-types-wrongAdding a component
- Create
src/components/MyThing/withMyThing.tsx,MyThing.module.css,index.ts, a test and a story. - Add
'use client'at the top of the.tsxonly if it uses hooks, events or browser APIs. - Re-export it from
src/index.ts. - Add an entry to
build.lib.entryinvite.config.tsand a matching subpath inexportsandtypesVersionsinpackage.json.
Keep CSS scoped to classes — no element or global selectors, so the package can never reset a consumer's styles.
Releasing
Releases run through Changesets.
Add a changeset with your PR:
pnpm changesetMerge to
main. CI opens a Version Packages PR that applies the version bump and updatesCHANGELOG.md.Merge that PR. CI publishes to npm with provenance.
This requires an NPM_TOKEN repository secret with publish rights. The package is published publicly (publishConfig.access: "public").
License
MIT
