CSS class naming conventions
There are only two hard things in Computer Science: cache invalidation and naming things.
— Phil Karlton
This page describes how YDS names classes in CSS Modules and in the bundled all.css. Follow this when you add or change styles in @yleisradio/yds-components-react.
There are three distributions from the same .module.css source:
| Distribution | Class names | Who uses it |
|---|---|---|
| As-is CSS Modules | Hashed by the app bundler | React apps |
| CSS Modules export | Hashed by the app bundler | Non-React web apps with a bundler step |
Bundled all.css | Stable, readable | CDN, prototypes, HTML without a bundler |
The hashed and bundled distributions share one source. To emit all.css names from CSS Modules instead of hashes, set the bundler’s generateScopedName — see Get started — Matching all.css class names. A regression test asserts the published all.css contains every selector of the unpublished rollup bundle.
Local names in the module are the contract. all.css only prefixes them.
CSS Modules outside React use the same local names as React. Import from @yleisradio/yds-components-react/Button.module.css (short) or @yleisradio/yds-components-react/styles/components/Button/Button.module.css (deep). See Get started — Without React.
Grammar
-- modifiers are not used: they are awkward as JavaScript identifiers (styles['variant--primary']). The separator is a single underscore.
yds-[Component] is added only when bundling all.css. Do not write that prefix in .module.css.
| Situation | Local class | all.css |
|---|---|---|
| Root | root | yds-Button |
| Element | TextWrapper | yds-Button__TextWrapper |
Boolean prop when true | isDisabled | yds-Button__isDisabled |
| Named prop value | variant_primary | yds-Button__variant_primary |
| Element + prop | TextWrapper_size_xs | yds-Button__TextWrapper_size_xs |
Elements are PascalCase (initial capital). Props are camelCase (initial lowercase). A consumer — or a small helper — can tell them apart without a lookup table:
- starts with
A–Z→ element; anything after the first_is a modifier - otherwise → prop on the root
In all.css:
yds-Button
yds-Button__variant_primary
yds-Button__isDisabled
yds-Button__TextWrapper
yds-Button__TextWrapper_size_xs
generateScopedName maps root to yds-[Component] with no __root suffix. Every other local name becomes yds-[Component]__[local].
Module name vs component name
[Component] is the module's basename for almost every module, but not all of them: some slots are styled in an internal base component while consumers know them by the name they import. Those are resolved through a map, so do not hand-write yds-[basename]:
| Module | all.css prefix |
|---|---|
BaseCheckbox.module.css | yds-Checkbox |
BaseRadio.module.css | yds-Radio |
BaseNotification.module.css | yds-Notification |
Modules with no public component of their own (CheckableLabel, ChoiceContainer, Message, SliderRange) keep their own name — they are internal chrome, not a slot you are meant to target. Combobox.module.css also keeps yds-Combobox even though the React export is ComboboxSingleSelect: the single/multi distinction is React-side, and a future ComboboxMultiSelect shares this styling.
The map ships with the package as @yleisradio/yds-components-react/scoped-name. Import it rather than reimplementing it — see Get started — Matching all.css class names.
Do not camelCase a prop into the element name. Title_size_md, never titleSize_md.
Building names from props
For all.css consumers the public React prop names are the vocabulary.
| Prop | Class |
|---|---|
variant="primary" | variant_primary |
size="md" | size_md |
isDisabled={true} | isDisabled |
disabled (html attribute) | root[disabled] (in addition to a possible prop class) |
Boolean classes are omitted when the value is false. Derived internals (hasIconBefore) should be avoided: prefer a sibling selector (.IconWrapper + .TextWrapper) so HTML consumers do not have to reconstruct React-only state. Button does this — .root:not(:has(.IconWrapper ~ .TextWrapper)) replaced its hasIconBefore.
TextInput is the documented exception. Its leading and trailing slots are not distinguishable from markup: iconClear and icon both render an IconWrapper_after, and submitButton is a slotted child whose classes come from another module. hasIcon, hasIconBefore, hasIconAfter and hasUnit therefore stay on .root. Reach for a derived class only when the structure genuinely cannot express the state, and say why in the module.
A helper is optional DX, not a second source of truth. If you add one, generate it from the same local names.
Root
root is the public node: the element that receives className, ref, and native props.
- Select:
rootis<select>, not the fieldset. The fieldset isFieldset; the chrome around the select isWrapper. - CheckboxGroup: the group component’s outermost node is
root, notContainer.Containeris only a grouping slot inside another component.
Every component module should define .root.
Modules without .root
These modules intentionally omit .root because the styled public node lives in another module:
| Module | Reason |
|---|---|
SearchInput.module.css | Only styles SubmitButton; the input root is TextInput.module.css. |
Slider.module.css | Only styles fieldset chrome; the control root is SliderRange.module.css. |
Two exports, one module
| Module | root maps to | Other outer slot |
|---|---|---|
Accordion.module.css | AccordionItem (the expandable panel) | List on the Accordion list wrapper |
PageIndicator.module.css | PageIndicator (the dot button) | Container on PageIndicatorContainer |
ToastNotification.module.css | ToastNotification (single toast wrapper) | Container on ToastNotifications |
SectionTabs.module.css | SectionTabs (the positioning wrapper) | List on SectionTabList, Tab on SectionTab |
NavigationTab and NavigationTabList used to share a module, which left the tab button — the node that receives className — without a root. They now have one module each. Prefer that split over a shared module whenever both exports render a public node.
Prop classes live on .root
Boolean and named prop classes (isDisabled, size_md, variant_primary, type_banner, isOpen) belong on .root in TSX. In CSS, style descendants with a cascade selector; use Element_prop_modifier only when the element is not a .root descendant or when the class is passed to another component’s root.
| Situation | CSS pattern | TSX |
|---|---|---|
| Root prop, element is a descendant | .isDisabled .Title { … } | isDisabled && styles.isDisabled on .root only |
Chrome that wraps .root (parent, not descendant) | .Wrapper:has(.error) { … } | prop class on .root only |
| Per-item state on a repeating row | .MenuItem_isSelected { … } | only when the prop varies per row and cannot live on .root |
Class passed via appearanceClassName onto Button | .GroupButton_isSelected { … } | prefix with the slot name — never bare isSelected |
| Element needs a shared primitive per prop value | .Title_size_md { composes: typography_md_bold from … } | styles[`Title_size_${size}`] on the element |
Prefer .prop .Element over .Element_prop whenever the prop applies to the whole component instance. When the chrome contains .root (a Wrapper around an <input>), cascade cannot reach it — use :has() from the wrapper (.Wrapper:has(.error)), not a second copy of the prop class on the wrapper or the fieldset. Use .Element_prop only for per-row state (Combobox menu items), when the styled node is outside the component subtree, or when the rule needs composes.
composes is a class-to-class link, so it is only valid on a rule whose selector is a single local class. .size_md .Title { composes: … } fails the build with composition is only allowed when selector is single :local class name. Split the rule: keep layout in the cascade selector and put the composition on Element_prop.
/* Accordion.module.css */
.Title_size_md {
composes: typography_md_bold from '../../internal/styles/typography.module.css';
padding: 0.75rem 1rem;
}
In CSS Modules, composes puts the composed class on the element next to Title_size_md. all.css has no such mechanism, so its build copies the composed declarations into the composing rule instead: .yds-Accordion__Title_size_md carries the typography itself. The copies come first, so the component's own declarations still win. Selectors that target the composed class (pseudo-classes, descendants) are rewritten onto the composing class too.
Do not write .Title.isDisabled or .Menu.isOpen: in all.css that produces yds-Accordion__isDisabled on a non-root node, which breaks the grammar.
/* Accordion.module.css — cascade from item root */
.isDisabled .Title {
color: var(--yds-color-text-disabled);
}
.iconAlign_start .Title {
justify-content: flex-start;
}
<div className={cx(styles.root, styles[`size_${size}`], isDisabled && styles.isDisabled)}>
<button className={cx(styles.Title, styles[`Title_size_${size}`])} aria-disabled={isDisabled} />
</div>
Regression tests in packages/yds-components-react/src/cssConventions.spec.ts enforce these rules against a parsed selector tree, so :not() and :has() cannot hide a compound. They also check that prop classes only land on the node carrying .root, that no prop class is copied onto a second node, that every styles[...] reference resolves to a declared local, and that each Element_prop family has a matching prop class on .root.
That last rule has an allowlist, because three cases legitimately have no root counterpart:
- the class is handed to another component's root (
GroupButton_*viaappearanceClassName); - it is per-row state that varies within one instance (
MenuItem_isSelected,Tab_isActive); - the styled node sits outside the root subtree (
Description_direction_horizontalis a sibling of.rootinside the fieldset).
A composes-only family is the fourth: Dialog's Title_size_* exists solely to attach typography, and size changes nothing else on the dialog, so no cascade rule declares a root size_*. Where a dimension needs both a composition and other declarations, write the cascade rule too — .menuSize_md .Menu declares the root class that MenuItem_menuSize_md then satisfies. Add an allowlist entry only when there is genuinely nothing to put on the root.
Use these names when the slot matches. Component-specific PascalCase names are allowed when nothing below fits. Do not invent synonyms (Box, Inner, Holder, Shell).
Shells
| Local | Meaning |
|---|---|
Wrapper | Chrome around a single control, not the root. Example: a div around <select>. |
Container | Chrome around several similar elements, inside another component. Example: Checkbox group with multiple Checkbox components. |
IconWrapper | Shell around an SVG or icon component. |
SpinnerWrapper | Shell around <Spinner>. |
[Slot]Wrapper | Other inner shell that may hold another component (TextWrapper, LabelWrapper). |
Form
| Local | Meaning |
|---|---|
Fieldset | <fieldset> |
Legend | <legend> — not Label |
Label | <label> or the node targeted by aria-labelledby |
Description | Longer helper text |
ErrorMessage | Error text |
Input | <input> when it is not root |
Option | <option> — Item is an <li> or selectable repeating row |
Unit | Suffix such as € or % |
Counter | Character counter |
If Label is a separate FormElementLabel component, its classes are yds-FormElementLabel, not yds-Select__Label.
Structure
| Local | Meaning |
|---|---|
Header | Top region |
Footer | Bottom region (chrome, not the button list) |
Title | Heading text |
Content | Main slot |
Actions | Action slot. Footer is the region; Actions is its contents. |
Overlay and disclosure
| Local | Meaning |
|---|---|
Dialog | Inner <dialog> chrome when it is not root. Modal and BottomSheet use root on <dialog>. |
Handle | Drag handle |
CloseButton | Close control — allowed because the slot is universal |
Trigger | Opening control when it is not Title |
Panel | Revealed region (tab / accordion) when you need to distinguish it from Content |
Menu | Popup list, not necessarily a <ul> |
Collections
| Local | Meaning |
|---|---|
List | <ul> / <ol> |
Item | <li> or repeating row |
Separator | Divider |
Nav | <nav> when it is not root |
Meters
| Local | Meaning |
|---|---|
Track | Track (progress, slider) |
Fill | Fill of the track |
Thumb | Slider knob, if it is its own node |
Indicator | One-of-N mark (page dots). Not the progress fill. |
Other recurring slots
| Local | Meaning |
|---|---|
Icon | The icon itself, usually SVG. The shell is IconWrapper. |
Badge | Decoration on another component |
Underlay | Hit area behind a control |
Button | Nested button that is not the root. Use SubmitButton when the slot is fixed. |
Not element names
- VisuallyHidden — the internal
VisuallyHiddenmodule (yds-VisuallyHiddeninall.css), notyds-Select__VisuallyHidden..yds-visually-hiddeninutilities.cssis the same treatment for markup outside YDS. - Backdrop — only if it is a real DOM node.
::backdropis not a class. - Spinner / Select / Checkbox as a child class — that is another component, not a local name on the parent.
Authoring
/* Button.module.css */
.root {
/* … */
}
.variant_primary {
background-color: var(--yds-color-action-primary);
}
.isDisabled {
cursor: not-allowed;
}
.TextWrapper {
display: block;
}
.TextWrapper_size_xs {
padding-left: 0.25rem;
}
.IconWrapper {
display: inline-block;
}
className={cx(
styles.root,
styles[`variant_${variant}`],
isDisabled && styles.isDisabled
)}
Shared primitives live in src/internal/styles/ (typography.module.css). Compose those; do not copy token values. Focus rings are written in the component module, not composed.
Cross-component styling
Do not add a second, kebab-case global BEM name (yds-Button__TextWrapper) next to the module class. This risks getting all.css to spread in micro-frontend architecture, where an app uses no hashes.
A parent must not select another component’s hashed internals. Instead:
- Put the style where the markup is. ButtonGroup applies Button’s own
inGroupclass fromButton.module.cssonto the Button root (viaclassNameorappearanceClassName). Button then styles.inGroup .TextWrapper. That is not a public React prop. - Pass color through a custom property. Parents set
--yds-spinner-stroke-color; Spinner’s wheel reads it with a token fallback. No:global(.yds-spinner)pierce.
Theme classes are a different contract: yds-theme-light, yds-theme-dark. They are global, kebab-case, and stable across every distribution — they are not component slots, so they carry no yds-[Component]__ prefix.
yds-use-focus, yds-focus, yds-focus-ring and yds-visually-hidden are the same kind of name, for elements outside YDS. YDS components do not use them.
Focus on YDS components
Each component states its focus ring in its own .module.css. Repeat the standard declaration, including the var() fallbacks, so the ring is visible without utilities.css:
.root:focus-visible {
outline: var(--yds-focus-outline-width, 3px) var(--yds-focus-outline-style, solid)
var(--yds-focus-outline-color, var(--yds-color-border-primary-focus));
outline-offset: var(--yds-focus-outline-offset, 2px);
}
A control with its own colour, offset, or extra declarations uses those in the same kind of rule. Do not select :global(.yds-focus) from a component module. That class is only for non-YDS elements.
Global utilities are an app import
Declare those classes in src/utilities.css, a plain stylesheet at the package root, not under internal/ and not in a .module.css. The file is only for app markup. Its rules are in @layer yds, same as the components. Left unlayered, they would beat every component focus ring and :where() would not protect those rings.
Next.js hardcodes mode: 'pure' for CSS Modules, and pure mode rejects any selector that contains no local class. It also rejects a global .css import whose importer lives inside node_modules. Importing utilities.css from the package barrel fails a Next.js build on every import from @yleisradio/yds-components-react.
Apps that need the classes import the file from their own entry, where global CSS is allowed (pages/_app or the root layout):
import '@yleisradio/yds-components-react/utilities.css';
all.css already appends the same file.
yds-use-focus on a wrapper (body, a layout root) draws the ring for :focus-visible in that subtree. yds-focus is the same ring on one element. yds-focus-ring shows it without :focus-visible. The subtree selector is :where(.yds-use-focus, .yds-use-focus *), so its specificity stays below a component's own :focus-visible rule.
internal/styles/disableBodyScroll.css stays a side-effect import from Modal and BottomSheet, because it targets body.
Pairing a global with a local in the same selector stays pure and is still fine inside a module:
:global(.yds-theme-dark) .root {
}
cssConventions.spec.ts asserts every selector in every module contains a local class, and that no component module targets :global(.yds-focus).
Restyling the focus ring
The ring is two properties — outline-offset is not part of the outline shorthand — so it cannot be a single value. The shorthand is composed from sub-tokens, so an app or a custom theme can change one part without restating the rest:
| Custom property | Default |
|---|---|
--yds-focus-outline-width | 3px |
--yds-focus-outline-style | solid |
--yds-focus-outline-color | unset — falls back to --yds-color-border-primary-focus |
--yds-focus-outline-offset | 2px |
--yds-focus-outline-color is deliberately not declared on :root, and there is no composed --yds-focus-outline shorthand token. A custom property substitutes its var() at the element that declares it, so resolving the colour on :root would freeze the root theme's value and inherit it into every .yds-theme-light / .yds-theme-dark subtree — scoped themes would draw the wrong focus colour. Reading the token at the use site instead keeps a consumer override working and follows a scoped theme. The same rule applies to any future token that references a theme-dependent value.
/* Thicker ring everywhere, colour and offset untouched */
:root {
--yds-focus-outline-width: 4px;
}
Any rule that draws the ring must apply both outline and outline-offset, and repeat the defaults as var() fallbacks, so the ring still shows when the app has not imported utilities.css. Importing that file is what publishes the custom properties on :root, which is how an app changes the ring everywhere.
What to avoid
- kebab-case locals or
--modifiers __element/_modifierprefixes in the module (they would triple-underscore aftergenerateScopedName)- Combinatorial classes that encode several props (
grid_banner_icon_actions_close) — use separate modifiers. Notification layout templates inBaseNotificationare a known exception: they are mutually exclusivegrid-template-areasmaps, not independent props. - Using the component name as the root class (
checkboxGroup,spinner,RadioBase,Dialog) — useroot - Chaining a prop class onto a non-root element (
.Menu.isOpen,.Grid.type_banner) — put the prop on.rootand cascade (.isOpen .Menu) or useElement_prop(.MenuItem_isSelected) - Documenting a parallel kebab namespace for
all.css - A rule in a
.module.csswhose selector has no local class (:global(.yds-focus) { … }) — it fails Next.js's pure mode. External classes belong insrc/utilities.css, which the app imports itself :global(.yds-focus)on a component selector — YDS focus is written in the component module
all.css class renames (3.1.0 convention fix)
all.css and direct CSS Module imports are not part of the versioned package API, so these renames ship in a minor release. If you referenced all.css names before this cleanup, update as follows:
| Component | Old local / all.css fragment | New |
|---|---|---|
| BaseRadio | RadioBase / yds-Radio__RadioBase | root / yds-Radio |
| Modal, BottomSheet | Dialog / yds-Modal__Dialog | root / yds-Modal |
| SectionTabs | TabContainer | root |
| ChoiceContainer | Container | root |
| Combobox | Wrapper (with isDisabled), isOpen on Menu | root (with isDisabled), isOpen on root |
| ToastNotification | Motion | root |
| PageIndicator | Indicator | root |
| NavigationTabList | Navigation | root |
| ButtonGroup | isSelected, isDisabled, size_* on Button | GroupButton_isSelected, GroupButton_isDisabled, GroupButton_size_* |
| DropdownMenuGroup | variant_*, size_*, isDisabled on Button | GroupButton_variant_*, GroupButton_size_*, GroupButton_isDisabled |
| Combobox (menu items) | isSelected, isHighlighted, isDisabled on MenuItem | MenuItem_isSelected, MenuItem_isHighlighted, MenuItem_isDisabled |
| Combobox (custom items) | isSelected, isHighlighted | CustomMenuItem_isSelected, CustomMenuItem_isHighlighted |
| Modal | contentScroll_body, contentScroll_content | removed — use scrollBehavior_body .Content |
| BaseCheckbox, BaseRadio, Message | isError | error (matches the error prop) |
| Button | hasIconBefore, hasIconAfter | removed — the rules match sibling order instead |
| Button | IconWrapper_size_*, IconWrapper_loading, TextWrapper_loading | removed — size_* and loading on root + cascade |
| Accordion | Title_iconAlign_*, Content_padding_* | iconAlign_*, padding_* on root + cascade |
| Combobox | Menu_size_*, MenuItem_size_* | menuSize_* on root, MenuItem_menuSize_* |
| Dialog | showTopBorder, showBottomBorder on Content | on root; FooterContainer/Footer → Footer/Actions |
| Dialog | Title_hasIllustrativeIcon, Footer_isResponsive | hasIllustrativeIcon on root + cascade |
| BottomSheet | SheetContent, DragHandle | Content, Handle |
| BottomSheet | Title_dragToClose, Title_noDragToClose | dragToClose on root |
| BaseNotification | Title_inline, Title_multiline, Text_inline, Text_multiline, Title_inlineWithChildren, CloseContainer | isMultiline on root + cascade; CloseWrapper |
| ChoiceContainer | IconContainer, size_sm | IconWrapper, containerSize_sm |
| DropdownMenuGroup | ButtonIcon | IconWrapper |
| Link | IconBefore | IconWrapper (it was used for both positions) |
| MenuItem | highlight | iconHighlight (matches the prop) |
| NavigationTab | TabItem, TabListItem, TabContent, BadgeContainer | root, Item, Content, Badge |
| NavigationTab | TabItem_selected, TabItem_removePadding, BadgeContainer_removePadding | selected, removePadding on root |
| NavigationTabList | TabList, TabList_align_center | List, align_center on root (own module) |
| ProgressBar | Track_status_*, Fill_status_*, Icon_status_*, Description_status_*, Fill_determinate, Fill_indeterminate | status_* and variant_* on root + cascade |
| RadioGroup | HorizontalRadioWrapper, HorizontalRadioLabelsWrapper, NumericScaleLabel, NumericScaleStartLabel, NumericScaleEndLabel | RadioWrapper, LabelsWrapper, ScaleLabel, StartLabel, EndLabel |
| SectionTabs | TabList, TabButton, TabButton_active | List, Tab, Tab_isActive |
| Select | isDisabled on Wrapper | on root (the <select>) |
| Spinner | background_variant_*, Wheel_size_*, Wheel_variant_* | Background/Arc elements; size_*/variant_* on root |
| TagFilter | Button_size_* | removed — size_* on root + cascade |
| TagLink | lineClamp, lineClampSingle, unlimitedLines | numberOfLines_single, numberOfLines_multiple, numberOfLines_unlimited |
| Textarea | CharacterCounter | Counter |
| TextInput | Wrapper_isDisabled, Wrapper_error, Wrapper_success, Wrapper_hasUnit, IconWrapper_isDisabled, Button_isDisabled, SpinnerWrapper_isDisabled, Unit_isDisabled | isDisabled, error, success, hasUnit on root; chrome uses :has() |
| ToastNotification | Notification, ProgressContainer, Notification_hasTimer, Notification_hasAction | Content, ProgressWrapper, showTimer, hideCloseButton on root (inverted: set when there is no close button) |
| CheckableLabel | visuallyHidden | yds-VisuallyHidden (internal module). .yds-visually-hidden remains in utilities.css for app markup |