Using styled-components with YDS 3.0
This guide is for apps that keep styled-components as their own styling library and need YDS design tokens to work with it after @yleisradio/yds-components-react 3.0.
If you are dropping styled-components, or never used it outside YDS, stop here: Breaking changes in React package 3.0 is enough.
In 2.x, YdsThemeProvider passed a theme object through React context. Styled components read colors from props.theme.yds:
const Card = styled.div`
background: ${(props) => props.theme.yds.BACKGROUND_VARIANT};
color: ${(props) => props.theme.yds.TEXT_DEFAULT};
`;
In 3.0 that context is gone. Colors are CSS custom properties, inherited through the DOM. The smallest change that gets you there is to import the same tokens directly, since defaultTheme from @yleisradio/yds-core already holds custom property references:
import { defaultTheme } from '@yleisradio/yds-core';
const Card = styled.div`
background: ${defaultTheme.BACKGROUND_VARIANT};
color: ${defaultTheme.TEXT_DEFAULT};
`;
Most of this work lands on 2.x, before you upgrade. defaultTheme and the token CSS files already ship in @yleisradio/yds-core 2.x, so you can convert every props.theme.yds read while still running the old package. Done in that order, the actual 3.0 upgrade is a version bump plus a bundler setting.
YDS no longer provides a styled-components ThemeProvider. Keep your own <ThemeProvider> if the app needs props.theme for non-YDS keys. Interpolate defaultTheme (or raw var(--yds-color-*)) for YDS colors.
What does not change
Worth establishing up front, because it removes a lot of perceived scope:
- JS token imports from
@yleisradio/yds-corekeep working.spacing,radius,typography,border,transition,colorand the theme objects are still plain JavaScript exports. Interpolatingspacing.SPACING_16into a styled component is as valid in 3.0 as it was in 2.x. This is also what makes the color migration cheap — seedefaultThemebelow. - Component props are unaffected. This migration is about tokens and theming, not component APIs.
ydsThemeUnitis still exported from@yleisradio/yds-components-reactif you use it for unit conversion.
What breaks is the theme context and nothing else: props.theme.yds.*, props.theme.ydsThemeName, props.theme.ydsThemeProps, and the unit / baseUnit props.
Two ways to read a color
There are two targets you can convert props.theme.yds.* to. Both end up as the same CSS custom property at runtime.
defaultTheme — the short path
@yleisradio/yds-core exports defaultTheme, a typed Theme object whose values are CSS custom property references with a built-in fallback:
import { defaultTheme } from '@yleisradio/yds-core';
defaultTheme.BACKGROUND; // 'var(--yds-color-background, #ffffff)'
That makes the migration a rename. The key names are identical to 2.x, so props.theme.yds. becomes defaultTheme. and nothing else changes:
props.theme.yds.TEXT_DEFAULT → defaultTheme.TEXT_DEFAULT
props.theme.yds.ACTION_PRIMARY → defaultTheme.ACTION_PRIMARY
Three properties make this the easiest place to start:
- It is typed.
Themedeclares all 106 tokens, so a mistyped name is a compile error. Hand-writtenvar(--yds-color-…)strings are unchecked, and an undefined custom property fails silently. - It carries fallbacks. Every value is
var(--yds-color-x, <light value>), so converted code renders correctly even before the token CSS is on:root. You can start here without doing Phase 1 first. - It still follows the live theme. These are variable references, not a snapshot of light values — the colors change with the document theme exactly as they did through context.
Raw var() — the destination
Writing the custom property directly drops the JavaScript indirection entirely. The rule is CONSTANT_CASE to kebab-case, prefixed with --yds-color-, with no exceptions or renames across all 106 tokens:
| 2.x | Raw CSS |
|---|---|
theme.yds.BACKGROUND | var(--yds-color-background) |
theme.yds.BACKGROUND_VARIANT | var(--yds-color-background-variant) |
theme.yds.TEXT_DEFAULT | var(--yds-color-text-default) |
theme.yds.TEXT_MEDIUM_EMPHASIS | var(--yds-color-text-medium-emphasis) |
theme.yds.ACTION_PRIMARY | var(--yds-color-action-primary) |
theme.yds.BORDER_SEPARATOR | var(--yds-color-border-separator) |
theme.yds.FEEDBACK_ERROR | var(--yds-color-feedback-error) |
This is where you want to end up if you plan to leave styled-components later, since the declarations become portable CSS with no imports.
Start with defaultTheme. It is a mechanical rename with compiler support, and it converts a large codebase in one pass. Move to raw var() opportunistically — or as part of Phase 6, if you leave styled-components at all. There is no need to choose one for the whole codebase.
Note that theme.yds contained colors only — including shadow colors (SHADOW_S, SHADOW_M, SHADOW_L), but not full box-shadow values. Spacing, radius and typography came from separate @yleisradio/yds-core imports and are unaffected.
Phase 0 — Inventory
Measure the work before starting. These searches cover everything that breaks:
# color reads — usually the bulk of the work
rg 'theme\.yds\.' --stats
# Theme-name branching and unit conversion
rg 'ydsThemeName|ydsThemeProps' --stats
# Unit props
rg 'YdsThemeProvider' -A 3 | rg 'unit|baseUnit'
# Where providers live, and how many
rg '<YdsThemeProvider' -l
# The type augmentation that makes all of the above compile
rg -l "declare module 'styled-components'"
The first number is the one that matters. It is a mechanical find-and-replace, so a large count means a long diff, not a hard problem.
The last search is the one to read first, because it determines whether the compiler helps you or hides the work — see the augmentation step below.
Phase 1 — Put tokens on :root (on 2.x)
Import the token CSS once from your app entry or global stylesheet. This is additive: nothing reads the variables yet.
/* yds-tokens.css */
@import '@yleisradio/yds-core/tokens/build/css/_border-radius.css';
@import '@yleisradio/yds-core/tokens/build/css/_spacing.css';
@import '@yleisradio/yds-core/tokens/build/css/_typography.css';
@import '@yleisradio/yds-core/tokens/build/css/_shadow-default.css';
@import '@yleisradio/yds-core/tokens/build/css/_shadow-default-prefers-dark.css';
@import '@yleisradio/yds-core/tokens/build/css/_theme-default.css';
@import '@yleisradio/yds-core/tokens/build/css/_theme-default-prefers-dark.css';
Pick the theme files that match what your app does today. If it is always light, omit the two *-prefers-dark files. If it is always dark, use _theme-default-dark.css and _shadow-default-dark.css instead. If a parent page already loads these files, do not import them again.
This phase and the next are independent — defaultTheme values fall back to their light value when a variable is missing, so Phase 2 does not wait on this. Do them in whichever order suits the team, but finish this one before the app needs dark mode.
Verify: in DevTools, html should list --yds-color-* variables. Nothing should look different yet.
Phase 2 — Convert color reads (on 2.x)
Delete the theme augmentation first
props.theme.yds only type-checks because something augments styled-components' DefaultTheme:
// styled.d.ts
declare module 'styled-components' {
export interface DefaultTheme {
ydsThemeName: ThemeName;
yds: Theme;
}
}
2.x shipped this file inside @yleisradio/yds-components-react, and many applications keep their own copy as well — usually an extended one, since ydsThemeProps was never in the packaged version.
Which of the two you have decides how the whole migration feels. If you relied on the packaged augmentation, 3.0 removes it for you and every unconverted read becomes a compile error the moment you upgrade. If you have your own copy, upgrading changes nothing for the compiler: the reads keep type-checking and fail at runtime instead, where props.theme no longer carries yds and the interpolation throws.
Delete the YDS fields from your own augmentation before converting anything. It costs one line and turns every remaining read into a type error, which is a far better worklist than a ripgrep count:
declare module 'styled-components' {
export interface DefaultTheme {
- ydsThemeName: ThemeName;
- yds: Theme;
- ydsThemeProps: { unit: string; baseUnit: number };
// your own theme keys stay
}
}
If the interface is left empty afterwards, delete the file — but only if your application has no theme of its own. See Phase 4 for why that distinction matters.
Convert the reads
Swap the theme object for defaultTheme, file by file. The token names do not change, so this is a find-and-replace of props.theme.yds. with defaultTheme. plus one import per file:
import { defaultTheme } from '@yleisradio/yds-core';
// Before
const Card = styled.div`
background: ${(props) => props.theme.yds.BACKGROUND_VARIANT};
border: 1px solid ${(props) => props.theme.yds.BORDER_SEPARATOR};
`;
// After
const Card = styled.div`
background: ${defaultTheme.BACKGROUND_VARIANT};
border: 1px solid ${defaultTheme.BORDER_SEPARATOR};
`;
The interpolations no longer depend on props, so they resolve once at module level instead of on every render.
Because defaultTheme values carry fallbacks, converted files render correctly whether or not Phase 1 has landed — the two phases are independent and can run in either order or in parallel. TypeScript checks every token name against the Theme type, so a rename that slipped through the find-and-replace fails the build rather than silently producing a missing color.
Going straight to raw `var()` instead
If a file is already close to plain CSS, or you know it is leaving styled-components soon, write the custom property directly and drop the import:
const Card = styled.div`
background: var(--yds-color-background-variant);
border: 1px solid var(--yds-color-border-separator);
`;
This has no fallback, so it does depend on Phase 1 being in place. It also has no compile-time checking — verify these visually.
Converting shadows at the same time
theme.yds carried shadow colors (SHADOW_S, SHADOW_M, SHADOW_L), not complete box-shadow values. Those you assembled yourself, or imported per theme and branched on:
import { lightShadow, darkShadow } from '@yleisradio/yds-core';
const Card = styled.div`
box-shadow: ${(props) => (props.theme.ydsThemeName === 'dark' ? darkShadow.SHADOW_MD : lightShadow.SHADOW_MD)};
`;
Shadows follow the same two paths as colors. defaultShadow mirrors defaultTheme — typed keys whose values are variable references with a light fallback — so the branch collapses to a single import:
import { defaultShadow } from '@yleisradio/yds-core';
defaultShadow.SHADOW_MD; // 'var(--yds-shadow-md, 0 0 16px 0 rgba(0, 0, 0, 0.15))'
const Card = styled.div`
box-shadow: ${defaultShadow.SHADOW_MD};
`;
Or write the variable directly, once Phase 1 has put the shadow CSS on :root:
const Card = styled.div`
box-shadow: var(--yds-shadow-md);
`;
lightShadow and darkShadow pin a theme, exactly like lightTheme and darkTheme. Reach for defaultShadow unless you specifically want a shadow that ignores the active theme.
Verify: the app looks identical. With defaultTheme the compiler catches wrong names; with raw var() watch for missing colors, since an undefined custom property resolves to nothing rather than erroring.
Phase 3 — Replace the remaining context reads (on 2.x)
Besides colors, the 2.x theme carried two things: the theme name, and the unit settings behind ydsThemeUnit.
Theme-name branching
Code that branched on props.theme.ydsThemeName usually exists because 2.x needed the theme name to pick a value. Most of those branches collapse, because the token already differs per theme:
// Before — two tokens selected in JS
color: ${(props) => (props.theme.ydsThemeName === 'dark' ? props.theme.yds.TEXT_LIGHT : props.theme.yds.TEXT_DARK)};
// After — one token that already follows the theme
color: var(--yds-color-text-default);
For a branch that is genuinely about theme rather than about a token, move the decision into CSS:
/* Follows the OS */
@media (prefers-color-scheme: dark) { … }
/* Follows a theme class on an ancestor */
.yds-theme-dark & { … }
If you need the active theme in JavaScript after upgrading, useThemeContext() returns the nearest provider's scope. It returns null outside a provider, which is the normal case once document tokens come from CSS.
Unit conversion
2.x put the provider's unit and baseUnit on the theme as ydsThemeProps, so ydsThemeUnit could convert every size to whatever the app was configured for:
font-size: ${(props) =>
ydsThemeUnit(
typography.DEFAULT_XL_FONT_SIZE,
props.theme.ydsThemeProps.unit,
props.theme.ydsThemeProps.baseUnit
)};
ydsThemeProps is gone in 3.0. ydsThemeUnit itself is still exported, so the smallest fix is to pass the values directly instead of reading them from the theme:
font-size: ${ydsThemeUnit(typography.DEFAULT_XL_FONT_SIZE, 'rem', 16)};
Most call sites can drop the helper altogether. A 2.x provider defaulted to rem at a 16px base, and 3.0 components emit exactly that, so unless yours set unit or baseUnit explicitly the conversion produces the value it was given — interpolate the typographyRem token or the CSS variable and delete the call.
Do not simply drop the arguments. ydsThemeUnit defaults to 'px', not to the 'rem' the provider defaulted to, so ydsThemeUnit(value) silently changes units. Pass 'rem' explicitly or remove the call.
If the app did configure unit="px" or a custom baseUnit, that setting disappears along with the props in Phase 4. Handle it once in PostCSS rather than at every call site.
Phase 4 — Upgrade to 3.0
Now the package upgrade itself, which should be small:
- Bump
@yleisradio/yds-components-reactto 3.0 and@yleisradio/yds-coreto the matching version. - Configure CSS Modules from
node_modules. Vite usually works as-is; Next.js needstranspilePackages; webpack 5 must notexclude: /node_modules/on the CSS rule. - Map CSS in your test runner. Components now import stylesheets. Jest needs a CSS proxy; Vitest needs the packages inlined and
css: false. - Remove
unitandbaseUnitfrom everyYdsThemeProvider. Component CSS now usesremon a1rem = 16pxbaseline. If your app relied onunit="px"or a custombaseUnit, addpostcss-rem-to-pixelorpostcss-pxtoremto your PostCSS chain instead. - Put your global CSS reset in a cascade layer. Component styles now live in
@layer yds. Recipe: Breaking changes — Global resets. If the reset is acreateGlobalStyle, see below. - Give styled-components a theme of its own, if it does not have one. 2.x rendered styled-components'
ThemeProvideron your behalf and replaced the theme with{ ydsThemeName, yds, ydsThemeProps }. 3.0 renders no styled-components provider at all, so ifYdsThemeProviderwas the only one in the tree,props.themeis now{}and everyprops.theme.*read breaks — not just the YDS ones. Render your own<ThemeProvider>with whatever the app needs. Conversely, if your provider sat outsideYdsThemeProvider, 2.x was shadowing it inside that subtree and those keys become visible again. - Keep your root provider for now by adding
scope=":root", so this step changes nothing about theming:
<YdsThemeProvider theme="light" scope=":root">
<App />
</YdsThemeProvider>
Note that theme now defaults to 'default' (follows prefers-color-scheme) rather than 'light'. If your app should stay light, pass theme="light" explicitly.
Verify: run the app. Anything that looks unstyled is almost always the bundler not processing CSS Modules from node_modules, not a token problem.
Phase 5 — Retire the root provider
Phase 1 already put tokens on :root via CSS, so the scope=":root" provider from Phase 4 is now doing the same job twice. Delete it:
-<YdsThemeProvider theme="light" scope=":root">
- <App />
-</YdsThemeProvider>
+<App />
Keep YdsThemeProvider where it earns its place — a section that needs a different theme than the page, or a theme that changes in React:
<YdsThemeProvider theme="dark">
<DarkSidebar />
</YdsThemeProvider>
If the app switches theme at runtime, keep one provider with scope=":root" and a stateful theme prop, or toggle a class on body and load the class-scoped token files. See YdsThemeProvider for the full API.
Verify: exactly one source defines document tokens. Two :root rules from different sources is the setup that produces "the theme is right until something re-renders".
Global CSS resets
The layer recipe lives in Breaking changes — Global resets. This section is only the styled-components part.
A createGlobalStyle reset is unlayered, so it beats @layer yds and strips component spacing:
// Unlayered, so every declaration here beats @layer yds
export const GlobalStyle = createGlobalStyle`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
`;
Leave box-sizing: border-box on *. Move margin: 0 and padding: 0 into a @layer reset in a plain CSS file, not in createGlobalStyle. styled-components injects its stylesheet when the component first renders, so a runtime-injected @layer reset, yds, app; is a coin flip against the bundler's CSS. Only styled-components 6 (stylis 4) reliably preserves @layer blocks inside createGlobalStyle anyway.
If you keep the reset itself in createGlobalStyle, wrap it in @layer reset { … } and declare the order in CSS that loads first.
If the reset cannot be layered at all, unwrap @layer yds in the app build.
Custom themes
Applications passing a generated theme object to the provider keep doing exactly that — customTheme() from @yleisradio/yds-theme-custom still returns a Theme, and YdsThemeProvider still accepts it:
const theme = customTheme(color.GRAY_95, color.NEWS_VIOLET_30);
<YdsThemeProvider theme={theme}>
<Widget />
</YdsThemeProvider>;
The difference is reach. In 2.x the palette flowed through React context to every descendant. In 3.0 it applies to the provider's subtree in the DOM. For a self-contained widget that is the same thing, and it is safer — the palette can no longer leak into a host page.
To emit a custom palette as static CSS instead, use themeToCss(selector, theme) from @yleisradio/yds-components-react.
Common problems
| Symptom | Cause |
|---|---|
| color missing entirely | Mistyped custom property in a hand-written var(). Undefined variables resolve to nothing rather than erroring — check the kebab-case spelling, or use defaultTheme so the compiler checks the name. |
| color or shadow renders, but ignores the theme | A pinned token object was imported. lightTheme / darkTheme and lightShadow / darkShadow hold literal values; only defaultTheme and defaultShadow hold custom property references that follow the active theme. |
props.theme.yds compiles, then throws at runtime | Your own styled.d.ts still augments DefaultTheme with yds. The type says it exists; 3.0 does not provide it. Delete those fields so the compiler lists the remaining reads. |
Every props.theme.* is suddenly undefined | 2.x supplied styled-components' theme through YdsThemeProvider. 3.0 does not render one, so an app without its own <ThemeProvider> has an empty theme. |
Sizes changed after dropping ydsThemeProps | ydsThemeUnit defaults to 'px', while a 2.x provider defaulted to 'rem'. Pass 'rem' explicitly or remove the call. |
| Tests fail parsing CSS after upgrading | Jest needs moduleNameMapper: { '\\.css$': 'identity-obj-proxy' }. |
| Components look unstyled after upgrading | The bundler is not processing CSS Modules from node_modules. |
| Every component lost its padding and margins | A global reset. Unlayered declarations beat @layer yds. Move the reset into a layer below yds — Breaking changes. If it is createGlobalStyle, see Global CSS resets. |
| The reset is in a layer, but still overrides YDS | The @layer reset, yds, app; statement is being parsed after the first YDS rule. Import the file that declares the order at the top of your entry. |
| App turned dark on some machines | theme now defaults to 'default', which follows prefers-color-scheme. Pass theme="light" to pin it. |
| Sizes shifted slightly | unit / baseUnit are gone; components emit rem at a 1rem = 16px baseline. Convert with PostCSS if the app uses a different root font size. |
| Spacing inconsistent between YDS and app code | JS spacing tokens are px strings (SPACING_16 === '16px') while component CSS uses rem. These agree at a 16px root and diverge otherwise — use var(--yds-spacing-16) to stay consistent. |
| Theme applies, then a portal renders unthemed | Content portalled out of the provider's subtree does not inherit its tokens. Apply useThemeContext().className to the portal container, or put tokens on :root. |
Related
- Get started with yds-components-react 3.0 — Install, tokens, first component.
- Breaking changes in React package 3.0 — Catalogue of what 3.0 breaks.
- CSS class naming conventions — Local names,
all.cssmapping, and element vocabulary. - YdsThemeProvider — Alternative to token CSS for a subtree or React-driven theme.
- Design Tokens — Token system and CSS-based theming.