A React component library based on the Quantum Design System
npm install --save @nearform/quantum
Tailwind 4.1.18 or newer is required.
Our components use
outline-hiddenandrounded-xs, which only exist in v4. On Tailwind v3 they resolve to nothing, so focus outlines are not reset and small radii render square.The
4.1.18floor is not cosmetic: Tailwind versions from4.0.0to4.1.17drop every theme key containing an uppercase letter (tailwindlabs/tailwindcss#18114). On those versions ourbrandGreentokens and theslideDown/slideUpanimations vanish with no error, so focus rings and the Accordion animation silently stop working while lowercase tokens keep resolving.Those camelCase names are deliberate and are not changing.
bg-brandGreen-100,text-brandMidnight-80andanimate-slideDownare the spellings in your markup; renaming them to v4's idiomaticbrand-greenwould rewrite every one of those class names in every consuming app. Raising the floor to4.1.18is the price of keeping them, and it is the cheaper of the two.Tailwind v4 also raises the browser baseline to Safari 16.4, Chrome 111 and Firefox 128. If you need to support anything older, stay on the previous release of this library.
hover:styles no longer apply on touch devices. v4 gates thehovervariant behind@media (hover: hover), which removes the sticky-hover-after-tap behaviour v3 had. Our hover styling is heaviest inButtonandPagination.
The plugin supplies our colour, shadow, font, stroke-width and animation tokens.
It also restores cursor: pointer on enabled button and [role="button"]
elements: v3's preflight set that and v4's does not, so without it every button
falls back to an arrow. It is registered in the base layer, so any cursor-*
utility you set still wins over it. Note it reaches your own buttons too, as v3's
preflight did — though unlike v3 it leaves disabled buttons alone.
You must also point Tailwind at this package so it scans our components for the
classes they use. The plugin does not do this for you — earlier versions
registered the path automatically, but Tailwind v4 replaced the content array
with source detection, and source detection skips node_modules by default. If
you omit this step the build succeeds and every Quantum utility is silently
missing from the output.
Our dark mode must be driven by a .dark class rather than the operating
system. Tailwind v4's dark: variant defaults to a prefers-color-scheme media
query, so each route below pins it back to the class — @custom-variant in the
CSS-first route, darkMode: 'class' in the JS-config ones.
All the examples below assume index.css sits one level down from your project
root, e.g. src/index.css, next to a tailwind.config.* at the root. Both the
@source path and the @config path are resolved relative to the CSS file,
so adjust the ../ if your layout differs — an @source that points outside
the project matches nothing and reports no error.
Tailwind v4 (CSS-first):
/* src/index.css */
@import 'tailwindcss';
@plugin '@nearform/quantum/tailwind-plugin';
@source '../node_modules/@nearform/quantum';
@custom-variant dark (&:is(.dark, .dark *));Tailwind v4 with a JS config. The config file is inert on its own. Unlike
v3, v4 loads a JS config only when a CSS entrypoint asks for it, so the
@config line below is required — without it the build succeeds and none of
our tokens are emitted:
/* src/index.css */
@import 'tailwindcss';
@config '../tailwind.config.mjs';// tailwind.config.mjs
import quantumPlugin from '@nearform/quantum/tailwind-plugin'
export default {
//...tailwind config
content: ['./node_modules/@nearform/quantum/dist'],
plugins: [quantumPlugin],
darkMode: 'class'
}Point content at the directory, as above. The form to avoid is the one where a
** has to traverse into dist:
// Do not do this
content: ['./node_modules/@nearform/quantum/**/*.js']Tailwind applies your .gitignore rules while expanding a ** pattern, so a
dist entry — the default in a Vite scaffold — makes it skip the very directory
our classes live in, and every Quantum utility silently disappears from the
output. Measured against this package at Tailwind 4.1.18 and 4.3.2, that
glob produces 4.5 kB with no brandGreen where the directory form produces
55 kB with it, and reports no error either way. It applies whether or not the
project is a git repository — the ignore file alone is enough.
Naming dist yourself avoids it, either as the bare directory above or as
'./node_modules/@nearform/quantum/dist/**/*.js'. The same split applies to
@source in the CSS-first route.
From a CommonJS config the plugin arrives as the default property, because the
CJS build uses exports.default. Omitting .default fails at build time with
is not a function:
/* src/index.css */
@import 'tailwindcss';
@config '../tailwind.config.cjs';// tailwind.config.cjs
const quantumPlugin = require('@nearform/quantum/tailwind-plugin').default
module.exports = {
//...tailwind config
content: ['./node_modules/@nearform/quantum/dist'],
plugins: [quantumPlugin],
darkMode: 'class'
}//root component
import '@nearform/quantum/global.css'
import { Button } from '@nearform/quantum'global.css carries our theme as CSS custom properties, and the utilities
read them through var() rather than having the values baked in, so a token can
be restyled without a Tailwind build:
@import '@nearform/quantum/global.css';
:root {
--color-accent: #123456;
}Your declaration is unlayered and ours is in the theme layer, so yours wins,
and every utility that reads the token follows it — bg-accent,
[&>*:focus]:bg-accent, dark:bg-accent-dark and the rest.
The variable name is the token name with its namespace in front:
--color-brandGreen-100, --color-foreground-muted, --shadow-brandGreen,
--font-sans, --stroke-width-2, --animate-slideDown. The same names are
available as JS objects — import { colors } from '@nearform/quantum'.
Only tokens our components actually use are emitted, so redeclaring one we do not reference has no effect; there is no utility reading it either way.
This applies to the prebuilt stylesheet only. On the Tailwind routes above the
plugin hands your build a JS theme and your build inlines the values, so there
is nothing to override at runtime — change them in your own @theme block or
Tailwind config instead.
Components target WCAG 2.2 level AA. Every
story is scanned with axe as part of
npm run test-storybook, against the wcag2a, wcag2aa, wcag21a,
wcag21aa and wcag22aa rule sets, so a component that loses its accessible
name, its focus indicator or its contrast fails CI.
Each story is scanned twice, once in light mode and once in dark.
A story that is a deliberate exception opts out through its own parameters:
parameters: { a11y: { disable: true } } // skip the story
parameters: { a11y: { config: { rules: [...] } } } // tune individual rulesSome things depend on the surrounding page, so the components take them as props rather than guessing:
-
Form controls need a label.
Input,Password,DateInputandTextareatakelabelText(rendered and wired up withhtmlFor) andhelpText(exposed througharia-describedby).Checkbox,Radio,SwitchandSelectTriggerhave no text of their own -- pair them withControlLabel, an external<label htmlFor>, or give them anaria-label. A placeholder is not a label. Inside aCheckboxGroupor aRadioGroup, the option'slabelprop does this, and itsdescriptionis wired up with it. -
A control inside a
FormGrouphas to pass its props on. The group derives the label'shtmlFor, the message ids behindaria-describedbyand thearia-invalidflag from one id and hands them to whichever direct child is the control. A wrapper of your own that drops them leaves the label pointing at an element that does not exist, and nothing looks wrong. Spread the props you are given, keep the parts as direct children, and reach foruseFormGroup()for a control the group cannot get to. -
Groups and landmarks need a name. Give
ButtonGroupanaria-labelwhen a page holds more than one, andPaginationalabelwhen it has more than one pagination nav.CheckboxGroupandRadioGrouptake alegend: without it the options are a run of controls that a reader arriving at the third one cannot place, and a form of several groups is one undifferentiated list. A group whose name is already on the page -- a heading directly above it -- takes anaria-labelledbypointing at that instead of repeating it. -
Avatars need a name, or none at all.
Avatarannouncesalt, falling back toname. Given neither it renders as decoration (aria-hidden), which is what you want when the person's name is already in the text beside it -- the initials themselves are never announced. -
Badges say their status in words.
Badgecolours its border to reinforce the text, never to replace it -- two identically-worded badges in different colours are indistinguishable to a good share of readers. A badge whose text is not self-explanatory, such as a bare count, takes anaria-label, which also gives it therole="img"that makes that label reach a screen reader. It never carries that role without a name, whether the role came from the badge or from you. Itsdisabledvariant is an appearance for a badge beside a disabled control, not a state of its own. -
Icon-only controls need names in your language.
Pagination(previousLabel,nextLabel,pageLabel),StepsIndicator(label,stepLabel),Input(clearLabel),Password(showLabel,hideLabel),DateInput(calendarLabel,formatHint,invalidMessage,rangeMessage),SplitButton(menuLabel),SwitchCard(removeLabel) andToastClose(label) all default to English and accept overrides.IconButtonhas no default to override: itslabelis required, because there is no name that could be guessed from an icon, and it should say what the button does rather than what the icon is a picture of -- "Delete article", not "bin". Where the name is also made visible, by aTooltipor otherwise, the two have to agree: WCAG 2.5.3 asks that the accessible name contain the visible text, so that someone speaking what they can see reaches the control they are looking at. -
Triggers should merge into the control they wrap.
ModalTrigger,PopoverTriggerandSelectTriggerrender a<button>of their own, so wrapping one around aButtonnests a control inside a control. PassasChildto merge them instead:<PopoverTrigger asChild> <Button>Open</Button> </PopoverTrigger>
Tooltipdoes this for you when its child is an element.
The token values live in src/theme.ts (built from src/colors and
src/animations) and the base styles in src/tailwind-base.ts. There is no
tailwind.config.* and no @config directive: src/quantum.css is a native
Tailwind v4 @theme block, generated from those files and committed, and it is
what both src/global.css and .storybook/global.css compile against.
After changing a token, regenerate it:
npm run build:theme
npm test fails if you forget.
To run tests for the project, run:
npm run testTo run Storybook tests for the project, run:
npm run test-storybookStories render twice by default, light and dark side by side. A fixed id
in a story is suffixed with --dark in the dark copy, along with the for
and aria-* references that point at it, so each copy's labels and
descriptions stay wired to their own controls. The Preview menu in the
toolbar switches to a single copy, which is better for wide components whose
layout depends on the window width.
The moon icon switches Storybook itself between light and dark. In single view it sets the story's mode too.
Popups that portal to document.body (Modal, Popover, Select, DateInput,
SortAndShow, SplitButton) take the theme of the pane they were opened from.
The test runner ignores side by side and scans a single copy of each story.
Just import:
import { Button, ButtonGroup } from '@nearform/quantum'And use:
<ButtonGroup>
<Button>One</Button>
<Button>Two</Button>
<Button>Three</Button>
</ButtonGroup>