Typing Styles in React: className, CSS Modules, and CSSProperties
Learn how to type conditional className logic, CSS Module imports, inline styles via React.CSSProperties, and CSS custom properties safely in TypeScript, so style bugs — typos, wrong variant names, mistyped variables — get caught at compile time instead of at runtime.
TypeScript can't check that a CSS class name you typed actually exists in a stylesheet, or that a color value is a valid CSS color — but it can catch an enormous number of real style bugs anyway, if you type your component's style-related props deliberately instead of leaving them as bare strings. This lesson covers the concrete patterns: typed className composition, typed CSS Module imports, React.CSSProperties for inline styles, and typed CSS custom properties for dynamic values.
Typing className and conditional class logic
The simplest className bug in TypeScript is a typo in a template string that nothing catches: className={btn ${isActive ? 'btn-active' : ''}} compiles fine even if btn-active doesn't exist in any stylesheet, because to TypeScript this is just a string. What TypeScript can enforce is the shape of the values that decide which classes get applied — for example, typing a variant prop as a union ('primary' | 'secondary' | 'danger') instead of string means passing an unlisted variant is a compile error, not a silent no-op at runtime.
A small typed helper for combining conditional classes (often called clsx or cn, matching the popular clsx package's API) keeps this logic readable and still fully typed: it accepts strings, and objects mapping a class name to a boolean, and returns the space-joined result of whichever ones are truthy.
type ClassValue = string | Record<string, boolean> | undefined | null | false; is enough to type such a helper's input — the return type is always string, so the result plugs directly into className without any casting.
Typing CSS Modules so imports aren't just any
By default, TypeScript doesn't know what import styles from './Button.module.css' should resolve to unless you tell it — without a declaration, that import is either a type error ('Cannot find module') or, if some loose config silently allows it, typed as any, which throws away every benefit of typing your styles in the first place, since styles.anyPropertyNameAtAll would compile even if that class doesn't exist.
The fix is a global ambient declaration (typically in a src/css-modules.d.ts file) telling TypeScript that any *.module.css import is an object mapping string keys to string values: declare module '*.module.css' { const classes: { [key: string]: string }; export default classes; }. This makes styles.card resolve to a string and gives you autocomplete on the object, though it's worth knowing this default declaration doesn't validate that .card actually exists in the CSS file — for that level of safety, a tool like typescript-plugin-css-modules generates a precise per-file type from the real class names, catching typos like styles.crad at compile time instead of getting undefined at runtime.
Either way, accessing a genuinely missing key from the loose declare module version returns undefined at runtime (React silently renders className={undefined}, which is harmless but useless) rather than throwing — which is exactly the gap the precise, per-file typed version closes.
React.CSSProperties and CSS custom properties in TypeScript
Inline styles in React (<div style={{...}}>) are typed against React.CSSProperties, which mirrors real CSS properties in camelCase (backgroundColor, not background-color) and rejects both invalid property names and, for many properties, invalid value shapes — e.g. width expects a number (treated as pixels) or a string, so width: true would be a compile error.
CSS custom properties (--my-color) used as inline style keys are a special case: React.CSSProperties doesn't know about arbitrary -- prefixed keys by default, since they aren't real, fixed CSS properties. The common, safe pattern is a small typed wrapper: interface CSSPropertiesWithVars extends React.CSSProperties { [key: --${string}]: string | number; } (a TypeScript template literal type), which accepts any --prefixed key while still getting full checking on every standard property.
This matters directly for EngiLipi-style dynamic styling — setting a component-specific CSS variable inline (style={{ '--progress': ${percent}% } as CSSPropertiesWithVars}) that a CSS Module rule then reads with var(--progress) — which is the standard, typed way to pass a runtime-computed value (like a progress percentage or a user-picked accent color) from a component into CSS without writing the value into a literal className string.
Safe style composition: theme tokens plus variant props
The pattern that ties all of this together in a real component is: a typed variant prop (a union, not string) selects between a small set of pre-defined CSS Module classes via the typed clsx-style helper, while any genuinely dynamic, per-instance value (a computed width, a live progress percentage, a user-chosen color) is passed as a typed inline style or CSS variable — never as a hand-built class name string like className={progress-${percent}}, since that requires a matching CSS rule to exist for every possible value, which doesn't scale and isn't type-checkable.
This composition should still always reference the app's existing design tokens rather than raw literal values — a dynamic inline color still ideally resolves to var(--color-primary) rather than a hardcoded hex, keeping even runtime-computed styles consistent with the rest of the themed app.
Typed variant classes + a typed CSS variable for a dynamic value
The css-modules.d.ts ambient declaration is what makes import styles from './ProgressBar.module.css' type-check at all — without it, TypeScript can't resolve a .css import. CSSPropertiesWithVars uses a template literal type (--${string}) to accept any custom-property key while keeping full checking on real CSS properties like the ones React.CSSProperties already knows. variant is a union type, not string, so passing an unlisted value like variant='danger' is a compile error, not a silent style miss. The dynamic percent value flows in through a CSS variable (--progress), read by the .fill class's width: var(--progress) rule in the CSS Module — never baked into a className string.
// css-modules.d.ts
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}
// cn.ts — a tiny typed class-combining helper
type ClassValue = string | Record<string, boolean> | undefined | null | false;
function cn(...values: ClassValue[]): string {
return values
.flatMap((v) => {
if (!v) return [];
if (typeof v === "string") return [v];
return Object.entries(v)
.filter(([, active]) => active)
.map(([className]) => className);
})
.join(" ");
}
// ProgressBar.tsx
import styles from "./ProgressBar.module.css";
interface CSSPropertiesWithVars extends React.CSSProperties {
[key: `--${string}`]: string | number;
}
type ProgressVariant = "default" | "success" | "warning";
interface ProgressBarProps {
percent: number;
variant?: ProgressVariant;
}
function ProgressBar({ percent, variant = "default" }: ProgressBarProps) {
const barStyle: CSSPropertiesWithVars = { "--progress": `${percent}%` };
return (
<div className={styles.track}>
<div
className={cn(styles.fill, { [styles.success]: variant === "success", [styles.warning]: variant === "warning" })}
styl
…Code Examples
The .fill rule reads the --progress custom property set inline by the component (with a 0% fallback via the var() second argument), while .success and .warning override only the background color and are toggled by the typed cn() helper based on the variant prop. Expected result: A progress bar whose fill width tracks the percent prop exactly, colored by variant, all fully typed from the component prop down to the CSS variable.
width: var(--progress, 0%);
Common mistake: Forgetting the var(--progress, 0%) fallback would leave the bar's width unset (and effectively 0 or auto, depending on other rules) on any render before the inline style is applied, or if the variable name is ever typo'd on one side.
/* ProgressBar.module.css */
.track {
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
height: 8px;
}
.fill {
height: 100%;
width: var(--progress, 0%);
background: var(--color-primary);
transition: width 0.2s ease;
}
.success { background: var(--color-success); }
.warning { background: var(--color-warning); }Important Rules
Type variant-style props (button kind, progress state, size) as string unions, never plain string — an invalid variant should be a compile error, not a silent styling gap.
Declare a global *.module.css ambient module (or use a tool that generates precise per-file types) so import styles from is actually type-checked instead of resolving to any.
Use React.CSSProperties for inline styles, and extend it with an indexed --${string} signature (a template literal type) when you need to set CSS custom properties inline.
Pass genuinely dynamic, per-instance values (computed percentages, live measurements, user-picked colors) through typed inline styles or CSS variables — never by string-building a className that requires a matching CSS rule to exist for every possible value.
Keep even dynamic/inline styles referencing the app's existing design tokens (var(--color-primary), not a raw hex) so runtime-computed styles stay consistent with the rest of the themed app.
Where this pattern is used across EngiLipi
EngiLipi's lesson progress bars, difficulty badges, and any per-subsystem accent coloring (the teal/purple/cyan/gold/orange/indigo/violet accents used for CS, Electronics, and the various embedded-systems courses) are exactly this pattern — a typed variant union choosing between pre-defined CSS Module classes, combined with typed CSS variables for any value that's computed at runtime rather than known ahead of time.
function Badge({ variant }: { variant: string }) { return <span className={badge badge-${variant}} />; } type BadgeVariant = "info" | "success" | "warning"; function Badge({ variant }: { variant: BadgeVariant }) {... }
With variant typed as plain string, calling <Badge variant="succes" /> (a typo) compiles fine and silently produces a badge-succes class that matches nothing in the stylesheet; with a union type, the same typo is a compile-time error.
<div className={progress-${percent}} /> // with a CSS rule needed per possible percent value
<div className={styles.fill} style={{ "--progress": ${percent}% } as CSSPropertiesWithVars} />
Building a class name string from a continuous numeric value would require a matching .progress-0 through .progress-100 CSS rule to exist for every integer, is untypeable in any meaningful way, and can't express fractional or animated values — passing the value through a typed CSS variable handles the full range with one CSS rule.
[multiple-choice] Why type a component's variant prop as 'primary' | 'secondary' | 'danger' instead of string? A. Union types make the component render faster B. It's required syntax for CSS Modules C. It turns an invalid or typo'd variant value into a compile-time error instead of a silent styling gap at runtime D. String props aren't allowed in React function components
C. It turns an invalid or typo'd variant value into a compile-time error instead of a silent styling gap at runtime — With a union type, TypeScript rejects any value outside the listed set at compile time; with plain string, a typo like 'dangr' compiles fine and just fails to match any CSS rule at runtime.
[multiple-choice] What problem does the declare module '*.module.css' { const classes: { [key: string]: string }; export default classes; } ambient declaration solve? A. It validates that every class name referenced in the component actually exists in the CSS file B. It makes import styles from './X.module.css' type-check as an object of string values, instead of failing to resolve or silently typing as any C. It compiles Sass files into CSS D. It enables hot module reloading for CSS
B. It makes import styles from './X.module.css' type-check as an object of string values, instead of failing to resolve or silently typing as any — The ambient declaration tells TypeScript what shape a *.module.css import has, so styles.card resolves to type string with autocomplete — though it doesn't by itself catch a reference to a class that doesn't actually exist in the file (a more precise per-file generator is needed for that).
[multiple-choice] How does React.CSSProperties need to be extended to safely accept a CSS custom property like --progress as an inline style key? A. It doesn't need extending — CSSProperties already accepts any -- prefixed key B. By casting the entire style object to any C. By adding an indexed signature using a template literal type, e.g. [key: \--${string}\]: string | number D. By defining --progress as a global CSS class instead of an inline style
C. By adding an indexed signature using a template literal type, e.g. [key: \--${string}\]: string | number — React.CSSProperties doesn't know about arbitrary custom-property keys by default; a template literal indexed signature for --${string} keys extends it to accept them while keeping full type checking on all standard CSS properties.
Add a size prop to the ProgressBar component above, typed as a union of 'sm' | 'md' | 'lg', that toggles between three new CSS Module classes (.sm, .md, .lg) on the .track element using the existing cn() helper.
type ProgressSize = "sm" | "md" | "lg"; interface ProgressBarProps { percent: number; variant?: ProgressVariant; size?: ProgressSize; } function ProgressBar({ percent, variant = "default", size = "md" }: ProgressBarProps) { const barStyle: CSSPropertiesWithVars = { "--progress": ${percent}% }; return ( <div className={cn(styles.track, { [styles.sm]: size === "sm", [styles.md]: size === "md", [styles.lg]: size === "lg" })}> <div className={cn(styles.fill, { [styles.success]: variant === "success", [styles.warning]: variant === "warning" })} style={barStyle} /> </div> ); }
Follow the same pattern already used for variant on .fill, but apply it to className={cn(styles.track, ...)} instead.