Senior Frontend Interview Guide, Part 6: CSS Modules and Reusable UI Components
Enterprises invest heavily in reusable components because they multiply the productivity of every team: a well-built DatePicker that is accessible, localized, and tested saves dozens of teams from writing (and getting wrong) their own. This chapter covers how to style components safely with CSS…

This article is Part 6 of 18 in The Senior Frontend Engineer Interview Guide, and includes 22 interview questions with model answers.
Enterprises invest heavily in reusable components because they multiply the productivity of every team: a well-built DatePicker that is accessible, localized, and tested saves dozens of teams from writing (and getting wrong) their own. This chapter covers how to style components safely with CSS Modules, how to design component APIs that are flexible without being confusing, how to build and govern a design system, and how to document and test components.
CSS Modules
A CSS Module is a CSS (or SCSS) file in which all class names and animation names are scoped locally by default. The build tool rewrites each class name to a unique identifier and exports a mapping object to JavaScript.
/* Button.module.scss */
.root {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font: inherit;
}
.primary { background: var(--color-primary); color: var(--color-on-primary); }
.secondary { background: transparent; border: 1px solid var(--color-border); }
.loading { cursor: progress; }
import styles from './Button.module.scss';
import clsx from 'clsx';
export function Button({ variant = 'primary', loading, className, ...rest }) {
return (
<button
className={clsx(styles.root, styles[variant], loading && styles.loading, className)}
aria-busy={loading || undefined}
{...rest}
/>
);
}
At build time .root becomes something like .Button_root__x7f3a. Two components can both have a .root class without collision.
Key Features
- Local by default. Use
:global(.some-class)to target a global class deliberately, such as a third-party library’s class. - Composition.
composes: base from './shared.module.css';adds another module’s class to the exported list without duplicating CSS. - Naming conventions. Bundlers can convert kebab-case to camelCase (
styles.primaryButton) via configuration (localsConvention). - TypeScript. Declare
*.module.scssmodules in a.d.tsfile, or use a plugin (typescript-plugin-css-modules,typed-scss-modules) to generate precise types so thatstyles.typois a compile error. - Zero runtime. Unlike runtime CSS-in-JS, CSS Modules produce static CSS files, so there is no style injection cost during rendering, which matters for performance and server rendering.
Configuration
Vite and Next.js support *.module.css and *.module.scss out of the box. In webpack:
{
test: /\.module\.s[ac]ss$/,
use: [
'style-loader', // or MiniCssExtractPlugin.loader for production
{
loader: 'css-loader',
options: {
modules: {
localIdentName: process.env.NODE_ENV === 'production'
? '[hash:base64:6]'
: '[name]__[local]--[hash:base64:4]',
exportLocalsConvention: 'camelCaseOnly',
},
},
},
'sass-loader',
],
}
In a micro frontend platform, include a unique prefix in localIdentName (for example payments_[hash]) so two remotes that happen to generate the same hash can never collide.
Styling Approaches Compared
| Approach | Scoping | Runtime cost | Theming | Notes |
|---|---|---|---|---|
| Global CSS / BEM | Convention only | None | Custom properties | Fragile at scale without strict discipline |
| CSS Modules | Build-time, automatic | None | Custom properties | Simple, framework-agnostic, great for design systems |
| Runtime CSS-in-JS (styled-components, Emotion) | Automatic | Style generation during render | Theme objects via context | Dynamic styles are easy; runtime cost and SSR complexity; React Server Components need extra setup |
| Zero-runtime CSS-in-JS (vanilla-extract, Linaria, Panda) | Automatic | None | Typed tokens | Type-safe; build tooling required |
| Utility-first (Tailwind CSS) | Utility classes | None | Config tokens | Fast development; long class lists; needs component abstraction |
There is no universally right answer. A good interview answer names the trade-offs and states which constraints (performance, SSR, team familiarity, theming needs, micro frontend isolation) drive the choice.
Principles of Reusable Components
A reusable component is not simply a component used twice. It has a deliberately designed API and behavior that works across contexts. The principles:
- Single responsibility. A
Buttonis a button. AConfirmPaymentButtonthat calls an API is an application component built on top of it. - Composition over configuration. Prefer
childrenand slots to an ever-growing list of boolean props. - Predictable, minimal API. Consistent prop names across the library (
size,variant,disabled,onChange), sensible defaults, and no surprising side effects. - Pass-through of native attributes. Spread remaining props onto the underlying element and forward refs so consumers can add
aria-*,data-testid, and event handlers. - Accessible by default. Correct roles, keyboard behavior, focus management, and labels built in, not left to consumers.
- Controlled and uncontrolled support. Accept
value/onChangefor controlled use anddefaultValuefor uncontrolled use. - Stylable without breaking. Accept
className(merged, not replaced), expose design tokens, and avoid hard-coded colors and sizes. - Localized. No hard-coded English strings; accept labels as props or use the i18n system.
- Documented and tested. Stories for every state, unit tests for behavior, accessibility tests, and visual regression tests.
The Boolean Prop Explosion
A common anti-pattern:
<Card title="Checking" showIcon iconLeft hasFooter footerAlignRight
isCompact isClickable showBadge badgeColor="green" />
Each new requirement adds a prop, and combinations multiply. The composable alternative:
<Card onClick={openAccount}>
<Card.Header>
<AccountIcon type="checking" />
<Card.Title>Checking</Card.Title>
<Badge tone="success">Active</Badge>
</Card.Header>
<Card.Body>...</Card.Body>
<Card.Footer align="end">...</Card.Footer>
</Card>
Component Patterns
Compound Components
Compound components share implicit state through Context so that the parent manages behavior while consumers control structure.
const TabsContext = createContext(null);
function Tabs({ value, defaultValue, onValueChange, children }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const selected = isControlled ? value : internal;
const select = useCallback(v => {
if (!isControlled) setInternal(v);
onValueChange?.(v);
}, [isControlled, onValueChange]);
const baseId = useId();
const ctx = useMemo(() => ({ selected, select, baseId }), [selected, select, baseId]);
return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>;
}
function TabList({ children, label }) {
return <div role="tablist" aria-label={label}>{children}</div>;
}
function Tab({ value, children }) {
const { selected, select, baseId } = useContext(TabsContext);
const isSelected = selected === value;
return (
<button role="tab" id={`${baseId}-tab-${value}`}
aria-selected={isSelected} aria-controls={`${baseId}-panel-${value}`}
tabIndex={isSelected ? 0 : -1} onClick={() => select(value)}>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const { selected, baseId } = useContext(TabsContext);
if (selected !== value) return null;
return (
<div role="tabpanel" id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`} tabIndex={0}>
{children}
</div>
);
}
Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panel = TabPanel;
(A production version also implements arrow-key navigation between tabs, covered in Chapter 10.)
Controlled and Uncontrolled Together
The Tabs example shows the standard approach: if value is provided, the component is controlled and calls onValueChange; otherwise it manages its own state from defaultValue. A reusable useControllableState hook encapsulates this logic for every component in the library.
Polymorphic Components (as prop)
Sometimes consumers need a component to render a different element: a Button that is actually a link.
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, 'as'>;
function Text<E extends React.ElementType = 'span'>({ as, ...props }: PolymorphicProps<E>) {
const Component = as ?? 'span';
return <Component {...props} />;
}
<Text as="h2" className={styles.heading}>Recent activity</Text>
Radix UI popularized an alternative: an asChild prop that merges the component’s props and behavior onto its single child element (<Button asChild><a href="/pay">Pay</a></Button>). Either way, ensure the result remains semantically correct: a link styled like a button must still be a link.
Headless Components
Headless components or hooks provide behavior, state, and accessibility without markup or styles. Teams supply their own visuals.
function useDisclosure({ defaultOpen = false } = {}) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
return {
open,
getButtonProps: (props = {}) => ({
...props,
'aria-expanded': open,
'aria-controls': id,
onClick: e => { props.onClick?.(e); setOpen(o => !o); },
}),
getPanelProps: (props = {}) => ({ ...props, id, hidden: !open }),
};
}
The “prop getter” pattern (getButtonProps) lets consumers add their own handlers while the hook composes them with its own.
Slots
Slots are named insertion points, implemented either as props that accept elements (<Dialog title={...} footer={...}>) or as compound subcomponents. They suit layouts with fixed regions.
Design Systems
A design system is the combination of design principles, design tokens, a component library, patterns, documentation, and the governance process that keeps them coherent. In a large enterprise it is a product with its own team, roadmap, and consumers.
Design Tokens
Tokens are named design decisions stored in a platform-agnostic format and transformed for each platform.
{
"color": {
"blue": { "600": { "value": "#1d4ed8" } },
"action": { "primary": { "value": "{color.blue.600}" } }
},
"space": { "2": { "value": "0.5rem" }, "4": { "value": "1rem" } },
"radius": { "md": { "value": "0.375rem" } }
}
Tools like Style Dictionary transform tokens into CSS custom properties, SCSS variables, TypeScript constants, and iOS/Android formats.
Tokens are typically layered:
- Primitive (global) tokens: raw values (
blue-600). - Semantic (alias) tokens: intent (
color-action-primary,color-text-danger). - Component tokens: specific to a component (
button-primary-background).
Components should only reference semantic or component tokens, so a rebrand or dark theme changes token values without touching component code. This is how a white-label banking platform can serve multiple brands from one code base.
:root {
--color-action-primary: #1d4ed8;
--color-text: #111827;
}
[data-theme='dark'] {
--color-action-primary: #93c5fd;
--color-text: #f9fafb;
}
[data-brand='partner-bank'] {
--color-action-primary: #0f766e;
}
Building the Library
- Monorepo (Nx, Turborepo, pnpm workspaces) with packages for tokens, core components, icons, and utilities.
- Bundling with tsup, Rollup, or Vite library mode, outputting ES modules, type declarations, and CSS, with
"sideEffects": ["*.css"]so JavaScript tree shaking works. - Peer dependencies for React and React DOM so consumers’ copies are used.
- Semantic versioning with Changesets or semantic-release; breaking changes only in major versions, with codemods to automate migrations.
- Deprecation policy: mark old APIs deprecated with console warnings in development and remove them in the next major release.
Documentation with Storybook
Storybook renders each component in isolation with stories for every important state.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Actions/Button',
component: Button,
args: { children: 'Continue' },
argTypes: { variant: { control: 'radio', options: ['primary', 'secondary', 'danger'] } },
};
export default meta;
export const Primary: StoryObj<typeof Button> = {};
export const Loading: StoryObj<typeof Button> = { args: { loading: true } };
export const Danger: StoryObj<typeof Button> = { args: { variant: 'danger', children: 'Delete payee' } };
Storybook addons provide accessibility checks (axe), interaction tests (play functions), and visual regression testing (Chromatic, Percy, or Playwright screenshots of stories). Documentation pages should show usage guidelines, do’s and don’ts, accessibility notes, and code examples.
Governance and Adoption
A design system fails if teams do not adopt it. Successful systems:
- Treat consuming teams as customers: office hours, a support channel, and clear contribution guidelines.
- Accept contributions through a defined process (proposal, design review, accessibility review, API review).
- Measure adoption (how many products use which version, how many custom overrides exist) with code-scanning tools.
- Provide escape hatches (
className, tokens, headless hooks) so teams are not blocked. - Keep a changelog and migration guides, and run upgrade campaigns for major versions.
Building a Production-Grade Input Component
Bringing the principles together:
type TextFieldProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> & {
label: string;
hint?: string;
error?: string;
size?: 'sm' | 'md' | 'lg';
};
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
function TextField({ label, hint, error, size = 'md', id, className, ...rest }, ref) {
const autoId = useId();
const inputId = id ?? autoId;
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
return (
<div className={clsx(styles.field, styles[size], className)}>
<label htmlFor={inputId} className={styles.label}>{label}</label>
{hint && <p id={hintId} className={styles.hint}>{hint}</p>}
<input
ref={ref}
id={inputId}
className={styles.input}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
{...rest}
/>
{error && <p id={errorId} className={styles.error}>{error}</p>}
</div>
);
}
);
What makes it reusable: a required visible label, IDs generated safely for SSR, hint and error associated through aria-describedby, native attributes passed through, ref forwarding for form libraries and focus management, className merged rather than replaced, and size variants driven by tokens.
Component Testing Strategy
| Level | Tool | What to verify |
|---|---|---|
| Unit/behavior | Jest or Vitest with React Testing Library | Props, states, callbacks, keyboard interaction |
| Accessibility | jest-axe, Storybook a11y addon, Playwright with axe | Roles, names, contrast, ARIA validity |
| Visual regression | Chromatic, Percy, Playwright screenshots | Unintended visual changes across themes and sizes |
| Integration | Playwright component testing or E2E in a consuming app | Real browser behavior, focus, portals |
Chapter 12 covers these tools in depth.
Interview Questions and Answers
Q1. What are CSS Modules, and how do they work?
CSS files where class names are locally scoped by default. The bundler (css-loader, Vite, Next.js) rewrites each class to a unique name and exports a JavaScript object mapping the original names to generated ones. Components import the object and apply styles.className. This prevents collisions without runtime cost.
Q2. How do you apply global styles or target third-party classes with CSS Modules?
Use :global(.class-name) inside a module, or put truly global styles (reset, typography base, tokens) in a separate non-module stylesheet imported once at the app root. Scope global overrides under a local class to limit their reach, for example .datePicker :global(.react-datepicker__day).
Q3. What does composes do?
It makes one local class include the class names of another local or imported class, so the element receives both classes. It reuses styles without duplicating CSS output and without Sass mixins.
Q4. CSS Modules versus styled-components: how would you choose?
CSS Modules give static CSS with no runtime cost, work well with SSR and Server Components, and are simple to adopt. Runtime CSS-in-JS makes dynamic, prop-driven styling ergonomic and co-locates styles but adds runtime overhead and SSR complexity. For a performance-sensitive design system used across micro frontends, I would choose CSS Modules or a zero-runtime solution with custom properties for theming.
Q5. What makes a component reusable?
A focused responsibility, a small and consistent API, composition through children and slots, native attribute pass-through and ref forwarding, built-in accessibility, support for controlled and uncontrolled use, theming through tokens, localization of strings, and thorough documentation and tests.
Q6. What are compound components?
A pattern where a parent component manages shared state and exposes subcomponents (Tabs.List, Tabs.Tab, Tabs.Panel) that read it through Context. Consumers control the structure and order while the parent controls behavior. It avoids long prop lists and makes the API expressive.
Q7. How do you support both controlled and uncontrolled usage?
Accept value and onChange for controlled mode and defaultValue for uncontrolled mode. Internally, keep local state and use it only when value is undefined, always calling onChange when the value changes. Warn in development if a component switches between modes. Encapsulate the logic in a useControllableState hook.
Q8. What are design tokens?
Named, platform-agnostic values for design decisions such as color, spacing, typography, radius, shadow, and motion. They are stored in a source format such as JSON and transformed into CSS variables and other platform outputs. Layering primitive, semantic, and component tokens enables theming and rebranding without changing components.
Q9. How would you implement theming across a design system?
Define semantic tokens as CSS custom properties at the root, override them for each theme with a selector such as [data-theme="dark"] or [data-brand="x"], and have components reference only semantic tokens. Respect prefers-color-scheme, persist user choice, apply the theme before first paint, and validate contrast for every theme.
Q10. What is a polymorphic component, and what are the risks?
A component that can render as different elements or components via an as prop or asChild. It increases flexibility (a button-styled link) but complicates typing and can produce invalid or inaccessible markup if consumers choose the wrong element. Type it with generics and document the allowed elements.
Q11. What is a headless component library, and when would you use one?
A library that provides behavior, state management, and accessibility without styles or markup, such as Radix UI primitives, React Aria, Headless UI, Downshift, or TanStack Table. Use it when you need full control over visuals (a custom brand) but do not want to reimplement complex accessible behavior like comboboxes, menus, and dialogs.
Q12. How do you version and release a component library?
Semantic versioning: patches for fixes, minors for backwards-compatible features, majors for breaking changes. Automate with Changesets or semantic-release from conventional commits, publish to a private registry, maintain a changelog, provide codemods for breaking changes, and support the previous major version for a defined period.
Q13. How do you prevent the design system from becoming a bottleneck?
Provide escape hatches (className, tokens, headless hooks), a clear contribution model so product teams can upstream components, fast release cycles, and good documentation. Allow product teams to build local components that later graduate into the system.
Q14. How do you document components?
Storybook with stories for each state and variant, auto-generated prop tables from TypeScript, usage guidelines with do’s and don’ts, accessibility notes (keyboard interactions, ARIA), live code examples, and links to the Figma source. Interaction tests in stories double as documentation and tests.
Q15. How do you ensure components stay accessible?
Build on semantic elements or proven headless primitives, include accessibility acceptance criteria in design reviews, run automated axe checks in unit tests and Storybook, test keyboard interaction in tests, perform manual screen reader testing for complex widgets, and document required props like labels.
Q16. How do you handle styling overrides from consumers?
Merge a consumer className with internal classes (never replace), expose component-level tokens or CSS custom properties for supported customizations, keep internal specificity low (single classes or :where()) so consumer classes can win, and use cascade layers so the library sits beneath application styles.
Q17. What is the “prop getter” pattern?
A headless hook returns functions like getInputProps(userProps) that merge the hook’s required props (ARIA attributes, handlers) with the consumer’s props, chaining event handlers so both run. It gives consumers control over markup while guaranteeing required behavior.
Q18. How would you test visual consistency of a component library?
Visual regression testing: render every story in a real browser across themes, viewports, and states, take screenshots, and compare them with approved baselines in CI using Chromatic, Percy, or Playwright. Review diffs as part of the pull request.
Q19. Why should a design system declare React as a peer dependency?
So the library uses the consuming application’s React instance rather than bundling its own. Two copies of React cause invalid hook call errors and broken context, and duplicate bundles waste bytes.
Q20. How do you make a library tree-shakeable?
Publish ES modules, avoid side effects at module top level, mark the package with "sideEffects": false (listing CSS files as exceptions), use named exports with one entry per component or preserve modules in the build, and avoid barrel files that import everything in development builds when possible.
Q21. How do you approach a request to add a new prop to a shared component?
Understand the underlying use case, check whether composition or an existing prop solves it, consider whether it generalizes to other teams, design the API consistently with existing conventions, include accessibility and design review, document it, and add tests and stories. Sometimes the right answer is a new component or a pattern documented on top of existing ones.
Q22. How do you migrate teams off a deprecated component?
Mark it deprecated with clear warnings and documentation pointing to the replacement, provide a codemod (jscodeshift or ts-morph) for mechanical changes, track usage across repositories with code search, offer support during migration, and remove it in the next major version after a communicated deadline.
Key Takeaways
- CSS Modules provide automatic, zero-runtime style scoping and fit design systems and micro frontends well.
- Reusable components favor composition, pass-through props, ref forwarding, built-in accessibility, and token-driven styling.
- Compound, headless, and polymorphic patterns each solve specific flexibility problems.
- A design system is a product: tokens, components, docs, versioning, governance, and adoption.
← Part 5: HTML, CSS, SCSS, and Responsive Design · All chapters · Part 7: State Management: Zustand, Context, and Asynchronous Data →







