Skip to main content

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:

DistributionClass namesWho uses it
As-is CSS ModulesHashed by the app bundlerReact apps
CSS Modules exportHashed by the app bundlerNon-React web apps with a bundler step
Bundled all.cssStable, readableCDN, 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.

SituationLocal classall.css
Rootrootyds-Button
ElementTextWrapperyds-Button__TextWrapper
Boolean prop when trueisDisabledyds-Button__isDisabled
Named prop valuevariant_primaryyds-Button__variant_primary
Element + propTextWrapper_size_xsyds-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]:

Moduleall.css prefix
BaseCheckbox.module.cssyds-Checkbox
BaseRadio.module.cssyds-Radio
BaseNotification.module.cssyds-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.

PropClass
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: root is <select>, not the fieldset. The fieldset is Fieldset; the chrome around the select is Wrapper.
  • CheckboxGroup: the group component’s outermost node is root, not Container. Container is 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:

ModuleReason
SearchInput.module.cssOnly styles SubmitButton; the input root is TextInput.module.css.
Slider.module.cssOnly styles fieldset chrome; the control root is SliderRange.module.css.

Two exports, one module​

Moduleroot maps toOther outer slot
Accordion.module.cssAccordionItem (the expandable panel)List on the Accordion list wrapper
PageIndicator.module.cssPageIndicator (the dot button)Container on PageIndicatorContainer
ToastNotification.module.cssToastNotification (single toast wrapper)Container on ToastNotifications
SectionTabs.module.cssSectionTabs (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.

SituationCSS patternTSX
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_* via appearanceClassName);
  • it is per-row state that varies within one instance (MenuItem_isSelected, Tab_isActive);
  • the styled node sits outside the root subtree (Description_direction_horizontal is a sibling of .root inside 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​

LocalMeaning
WrapperChrome around a single control, not the root. Example: a div around <select>.
ContainerChrome around several similar elements, inside another component. Example: Checkbox group with multiple Checkbox components.
IconWrapperShell around an SVG or icon component.
SpinnerWrapperShell around <Spinner>.
[Slot]WrapperOther inner shell that may hold another component (TextWrapper, LabelWrapper).

Form​

LocalMeaning
Fieldset<fieldset>
Legend<legend> — not Label
Label<label> or the node targeted by aria-labelledby
DescriptionLonger helper text
ErrorMessageError text
Input<input> when it is not root
Option<option> — Item is an <li> or selectable repeating row
UnitSuffix such as € or %
CounterCharacter counter

If Label is a separate FormElementLabel component, its classes are yds-FormElementLabel, not yds-Select__Label.

Structure​

LocalMeaning
HeaderTop region
FooterBottom region (chrome, not the button list)
TitleHeading text
ContentMain slot
ActionsAction slot. Footer is the region; Actions is its contents.

Overlay and disclosure​

LocalMeaning
DialogInner <dialog> chrome when it is not root. Modal and BottomSheet use root on <dialog>.
HandleDrag handle
CloseButtonClose control — allowed because the slot is universal
TriggerOpening control when it is not Title
PanelRevealed region (tab / accordion) when you need to distinguish it from Content
MenuPopup list, not necessarily a <ul>

Collections​

LocalMeaning
List<ul> / <ol>
Item<li> or repeating row
SeparatorDivider
Nav<nav> when it is not root

Meters​

LocalMeaning
TrackTrack (progress, slider)
FillFill of the track
ThumbSlider knob, if it is its own node
IndicatorOne-of-N mark (page dots). Not the progress fill.

Other recurring slots​

LocalMeaning
IconThe icon itself, usually SVG. The shell is IconWrapper.
BadgeDecoration on another component
UnderlayHit area behind a control
ButtonNested button that is not the root. Use SubmitButton when the slot is fixed.

Not element names​

  • VisuallyHidden — the internal VisuallyHidden module (yds-VisuallyHidden in all.css), not yds-Select__VisuallyHidden. .yds-visually-hidden in utilities.css is the same treatment for markup outside YDS.
  • Backdrop — only if it is a real DOM node. ::backdrop is 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:

  1. Put the style where the markup is. ButtonGroup applies Button’s own inGroup class from Button.module.css onto the Button root (via className or appearanceClassName). Button then styles .inGroup .TextWrapper. That is not a public React prop.
  2. 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 propertyDefault
--yds-focus-outline-width3px
--yds-focus-outline-stylesolid
--yds-focus-outline-colorunset — falls back to --yds-color-border-primary-focus
--yds-focus-outline-offset2px

--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 / _modifier prefixes in the module (they would triple-underscore after generateScopedName)
  • Combinatorial classes that encode several props (grid_banner_icon_actions_close) — use separate modifiers. Notification layout templates in BaseNotification are a known exception: they are mutually exclusive grid-template-areas maps, not independent props.
  • Using the component name as the root class (checkboxGroup, spinner, RadioBase, Dialog) — use root
  • Chaining a prop class onto a non-root element (.Menu.isOpen, .Grid.type_banner) — put the prop on .root and cascade (.isOpen .Menu) or use Element_prop (.MenuItem_isSelected)
  • Documenting a parallel kebab namespace for all.css
  • A rule in a .module.css whose selector has no local class (:global(.yds-focus) { … }) — it fails Next.js's pure mode. External classes belong in src/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:

ComponentOld local / all.css fragmentNew
BaseRadioRadioBase / yds-Radio__RadioBaseroot / yds-Radio
Modal, BottomSheetDialog / yds-Modal__Dialogroot / yds-Modal
SectionTabsTabContainerroot
ChoiceContainerContainerroot
ComboboxWrapper (with isDisabled), isOpen on Menuroot (with isDisabled), isOpen on root
ToastNotificationMotionroot
PageIndicatorIndicatorroot
NavigationTabListNavigationroot
ButtonGroupisSelected, isDisabled, size_* on ButtonGroupButton_isSelected, GroupButton_isDisabled, GroupButton_size_*
DropdownMenuGroupvariant_*, size_*, isDisabled on ButtonGroupButton_variant_*, GroupButton_size_*, GroupButton_isDisabled
Combobox (menu items)isSelected, isHighlighted, isDisabled on MenuItemMenuItem_isSelected, MenuItem_isHighlighted, MenuItem_isDisabled
Combobox (custom items)isSelected, isHighlightedCustomMenuItem_isSelected, CustomMenuItem_isHighlighted
ModalcontentScroll_body, contentScroll_contentremoved — use scrollBehavior_body .Content
BaseCheckbox, BaseRadio, MessageisErrorerror (matches the error prop)
ButtonhasIconBefore, hasIconAfterremoved — the rules match sibling order instead
ButtonIconWrapper_size_*, IconWrapper_loading, TextWrapper_loadingremoved — size_* and loading on root + cascade
AccordionTitle_iconAlign_*, Content_padding_*iconAlign_*, padding_* on root + cascade
ComboboxMenu_size_*, MenuItem_size_*menuSize_* on root, MenuItem_menuSize_*
DialogshowTopBorder, showBottomBorder on Contenton root; FooterContainer/Footer → Footer/Actions
DialogTitle_hasIllustrativeIcon, Footer_isResponsivehasIllustrativeIcon on root + cascade
BottomSheetSheetContent, DragHandleContent, Handle
BottomSheetTitle_dragToClose, Title_noDragToClosedragToClose on root
BaseNotificationTitle_inline, Title_multiline, Text_inline, Text_multiline, Title_inlineWithChildren, CloseContainerisMultiline on root + cascade; CloseWrapper
ChoiceContainerIconContainer, size_smIconWrapper, containerSize_sm
DropdownMenuGroupButtonIconIconWrapper
LinkIconBeforeIconWrapper (it was used for both positions)
MenuItemhighlighticonHighlight (matches the prop)
NavigationTabTabItem, TabListItem, TabContent, BadgeContainerroot, Item, Content, Badge
NavigationTabTabItem_selected, TabItem_removePadding, BadgeContainer_removePaddingselected, removePadding on root
NavigationTabListTabList, TabList_align_centerList, align_center on root (own module)
ProgressBarTrack_status_*, Fill_status_*, Icon_status_*, Description_status_*, Fill_determinate, Fill_indeterminatestatus_* and variant_* on root + cascade
RadioGroupHorizontalRadioWrapper, HorizontalRadioLabelsWrapper, NumericScaleLabel, NumericScaleStartLabel, NumericScaleEndLabelRadioWrapper, LabelsWrapper, ScaleLabel, StartLabel, EndLabel
SectionTabsTabList, TabButton, TabButton_activeList, Tab, Tab_isActive
SelectisDisabled on Wrapperon root (the <select>)
Spinnerbackground_variant_*, Wheel_size_*, Wheel_variant_*Background/Arc elements; size_*/variant_* on root
TagFilterButton_size_*removed — size_* on root + cascade
TagLinklineClamp, lineClampSingle, unlimitedLinesnumberOfLines_single, numberOfLines_multiple, numberOfLines_unlimited
TextareaCharacterCounterCounter
TextInputWrapper_isDisabled, Wrapper_error, Wrapper_success, Wrapper_hasUnit, IconWrapper_isDisabled, Button_isDisabled, SpinnerWrapper_isDisabled, Unit_isDisabledisDisabled, error, success, hasUnit on root; chrome uses :has()
ToastNotificationNotification, ProgressContainer, Notification_hasTimer, Notification_hasActionContent, ProgressWrapper, showTimer, hideCloseButton on root (inverted: set when there is no close button)
CheckableLabelvisuallyHiddenyds-VisuallyHidden (internal module). .yds-visually-hidden remains in utilities.css for app markup

See also​