| name | react-styling |
| description | React styling, design tokens, responsive layout, and motion — Tailwind, CSS Modules, CSS-in-JS, design tokens (CSS custom properties), breakpoints, container queries, CSS animations, Framer Motion, dark mode. Use when styling React components, implementing responsive design, building from Figma, adding animations, or setting up a design token system. Triggers on "Tailwind", "CSS", "styling", "responsive", "animation", "motion", "design tokens", "dark mode". |
react-styling
How a Senior UX Engineer styles React: design-token-driven, responsive by default, motion that respects users, and zero magic numbers.
The "no magic numbers" rule
The fastest senior-vs-mid signal in any review:
<div className="mt-[17px] text-[#3a3a3a]">…</div>
<div className="mt-4 text-neutral-800">…</div>
Every spacing, color, type-size, border-radius, and shadow value should be a token. If you find yourself typing 17px or #3a3a3a, stop. Either there's an existing token close enough, or there's a missing token you should add.
Design tokens via CSS custom properties
This works in any stack (Next, Vite, Astro). Define once, use everywhere:
:root {
--color-bg: hsl(0 0% 100%);
--color-fg: hsl(0 0% 10%);
--color-fg-muted: hsl(0 0% 40%);
--color-brand: hsl(173 80% 40%);
--color-brand-hover: hsl(173 80% 35%);
--color-border: hsl(0 0% 90%);
--color-danger: hsl(0 70% 50%);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--font-size-3xl: 3rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px hsl(0 0% 0% / 0.06);
--shadow-md: 0 4px 12px hsl(0 0% 0% / 0.08);
--shadow-lg: 0 12px 32px hsl(0 0% 0% / 0.12);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms;
--duration-base: 250ms;
--duration-slow: 400ms;
}
[data-theme="dark"] {
--color-bg: hsl(0 0% 7%);
--color-fg: hsl(0 0% 96%);
--color-fg-muted: hsl(0 0% 65%);
--color-border: hsl(0 0% 18%);
}
Why CSS custom properties beat JS-only tokens: they cascade, they animate, they switch theme without re-rendering React, and they work in <style> attributes.
Tailwind: the senior playbook
Configure tokens in tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
bg: 'hsl(var(--color-bg) / <alpha-value>)',
fg: 'hsl(var(--color-fg) / <alpha-value>)',
brand: {
DEFAULT: 'hsl(var(--color-brand) / <alpha-value>)',
hover: 'hsl(var(--color-brand-hover) / <alpha-value>)',
},
},
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
},
},
},
} satisfies Config;
This pattern (Tailwind classes → CSS variables → tokens) is what Radix UI, shadcn/ui, and every modern design system uses. Theme switching is just changing the variable values.
Tailwind patterns to use
-
cn helper for conditional classes:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
<button className={cn('btn', variant === 'primary' && 'btn-primary', className)} />
-
cva (class-variance-authority) for variant components:
import { cva } from 'class-variance-authority';
const button = cva('inline-flex items-center justify-center font-medium rounded-md transition', {
variants: {
variant: {
primary: 'bg-brand text-white hover:bg-brand-hover',
ghost: 'bg-transparent text-fg hover:bg-fg/5',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
});
-
Arbitrary values sparingly — if you need mt-[17px], you probably need a missing token. Use mt-[var(--specific-thing)] if it's a one-off architectural decision.
Tailwind smells
- Long, unreadable class strings → extract a component or use
cva
- Inline conditional with
? chains → use cn helper
!important (!) for anything outside of utility overrides → fix specificity instead
- Inline arbitrary colors → add a token
CSS Modules (alternative when Tailwind isn't on the table)
.button {
display: inline-flex;
align-items: center;
padding: var(--space-2) var(--space-4);
background: var(--color-brand);
color: white;
border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease);
}
.button:hover { background: var(--color-brand-hover); }
.button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.ghost {
background: transparent;
color: var(--color-fg);
}
.ghost:hover { background: hsl(var(--color-fg) / 0.06); }
import s from './Button.module.css';
import { cn } from '@/lib/cn';
<button className={cn(s.button, variant === 'ghost' && s.ghost)} />
Responsive: mobile-first by default
<div className="
grid
grid-cols-1 gap-4 p-4
md:grid-cols-2 md:gap-6 md:p-8
lg:grid-cols-3
">
.grid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); }
@media (min-width: 768px) {
.grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
}
@media (min-width: 1024px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
Common breakpoints (Tailwind defaults are sensible):
sm 640px — phone landscape
md 768px — tablet
lg 1024px — small laptop
xl 1280px — desktop
2xl 1536px — large desktop
Test at 320px wide. If it breaks there, it's not responsive.
Container queries (use when the layout depends on the container, not the viewport)
.card-container { container-type: inline-size; }
.card { display: grid; grid-template-columns: 1fr; }
@container (min-width: 400px) {
.card { grid-template-columns: 1fr 2fr; }
}
<div className="@container">
<div className="grid grid-cols-1 @md:grid-cols-2">…</div>
</div>
Container queries are a strong senior signal because most candidates still use viewport queries everywhere. Use them when a card looks different in a sidebar vs. a full-width grid.
Layout: flex vs. grid
- Grid: 2D layouts, page-level layouts, anything aligned in both rows and columns
- Flex: 1D layouts — a row of buttons, a vertical stack, "push items to the ends"
{}
<nav className="flex items-center justify-between">
<Logo />
<div className="flex items-center gap-4">…</div>
</nav>
{}
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-6">
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)) is one of the most useful patterns: cards reflow without media queries.
Animation — the senior playbook
Animate the cheap properties only
GPU-accelerated, no layout: transform, opacity, filter.
Expensive (cause layout/paint): width, height, top, left, margin.
.menu-enter { left: -300px; transition: left 0.3s; }
.menu-enter-active { left: 0; }
.menu-enter { transform: translateX(-100%); transition: transform 0.3s var(--ease); }
.menu-enter-active { transform: translateX(0); }
Always respect prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
CSS transitions for state, keyframes for ongoing motion
.btn { transform: scale(1); transition: transform var(--duration-fast) var(--ease); }
.btn:active { transform: scale(0.98); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton { animation: pulse 1.5s ease-in-out infinite; }
Framer Motion (use sparingly, only when CSS can't)
import { motion, AnimatePresence } from 'framer-motion';
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
…
</motion.div>
)}
</AnimatePresence>
Use Framer when:
- You need exit animations (CSS-only can't animate elements leaving the DOM cleanly)
- You need orchestration (stagger, sequence)
- You need gestures (drag, swipe)
Otherwise CSS wins on bundle size and perf.
Scroll-triggered animation
Use IntersectionObserver, not scroll listeners:
function FadeInOnScroll({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && setVisible(true),
{ rootMargin: '0px 0px -10% 0px' }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(12px)',
transition: 'opacity 400ms var(--ease), transform 400ms var(--ease)',
}}
>
{children}
</div>
);
}
For Tailwind, animation-delay utilities + the new @starting-style or libraries like react-intersection-observer work too.
Dark mode
Three approaches, ranked:
-
class strategy + CSS variables (recommended): toggle a class on <html>, all tokens flip via cascade.
<html class="dark">
:root { --color-bg: white; }
.dark { --color-bg: black; }
In Tailwind: configure darkMode: 'class'.
-
prefers-color-scheme only: respects system, no toggle.
@media (prefers-color-scheme: dark) { :root { --color-bg: black; } }
-
Hybrid: default to system, allow override via class.
Always test:
- Contrast in both modes
- Initial render — prevent flash of wrong theme (FOUC) by setting class on
<html> synchronously before React hydrates (Next.js: a small inline script in <head>)
Modern CSS — the platform features worth knowing
These ship in evergreen browsers and let you delete React/JS in many cases. Most candidates still write 2018-era CSS while these newer features solve the same problems with less code.
:has() — parent selector
.card:has(.badge--featured) {
background: hsl(173 80% 95%);
}
form:has(:user-invalid) .submit-warning {
display: block;
}
article:has(figure) {
padding-block: 2rem;
}
Removes the need for state-driven className toggles in many cases.
:focus-within
.field:focus-within {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.field:focus-within .hint {
opacity: 1;
}
Highlights a container when any descendant has focus — common for form fields, dropdowns, cards.
:user-valid and :user-invalid
input:user-invalid {
border-color: var(--color-danger);
}
input:user-valid {
border-color: var(--color-success);
}
Matches after the user has interacted. Unlike :invalid, which matches on blank required fields before the user even tries.
light-dark() and color-scheme
:root {
color-scheme: light dark;
--color-bg: light-dark(white, hsl(0 0% 7%));
--color-fg: light-dark(hsl(0 0% 10%), hsl(0 0% 96%));
}
Theme switching without @media (prefers-color-scheme) blocks. color-scheme also tells the browser to use light/dark form controls + scrollbars.
color-mix() — derive colors at the CSS layer
:root {
--color-brand: hsl(173 80% 40%);
--color-brand-hover: color-mix(in oklch, var(--color-brand), black 10%);
--color-brand-tint: color-mix(in oklch, var(--color-brand), white 90%);
}
Define one brand color, derive hover/tint/shade without a build step. in oklch keeps the mix perceptually uniform.
@layer — order cascade explicitly
@layer reset, base, components, utilities;
@layer base {
h1 { font-size: 2rem; }
}
@layer components {
.card { padding: 1rem; }
}
@layer utilities {
.text-center { text-align: center; }
}
Layers listed later win, regardless of specificity. Predictable cascade without specificity wars or !important.
@scope — scope styles without naming hacks
@scope (.card) {
h2 { font-size: 1.5rem; }
}
CSS-Modules-like scoping without the build step. Chromium 118+, Safari/Firefox behind flag — keep as progressive enhancement.
text-wrap: balance and text-wrap: pretty
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }
Tiny additions, huge typographic upgrade.
interpolate-size: allow-keywords
details {
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
transition: block-size 0.3s ease;
}
details[open]::details-content {
block-size: auto;
}
Finally enables smooth transition to/from auto — accordion open/close without JS measurement. Chromium 131+ only as of mid-2026.
scrollbar-gutter: stable
html {
scrollbar-gutter: stable;
}
Reserves scrollbar space even when not visible. Prevents layout jumps when content height changes (e.g., modal opens → scrollbar disappears → page jumps).
Anchor positioning (cross-reference)
For positioning popovers/tooltips relative to triggers without JS — see react-html-platform for the full pattern.
Browser support cheat (mid-2026)
:has() — universal
:focus-within — universal
:user-valid / :user-invalid — universal
light-dark() — universal
color-mix() — universal
@layer — universal
@scope — Chromium 118+, others behind flag
text-wrap: balance — universal
text-wrap: pretty — Chromium 117+, others catching up
interpolate-size: allow-keywords — Chromium 131+ only
scrollbar-gutter — universal
- Anchor positioning — Chromium 125+, others behind flag
For not-yet-universal features, fallback is usually "no animation" or "default positioning" — degrades gracefully.
Common styling mistakes (interviewer-visible)
| Mistake | Fix |
|---|
style={{ color: '#3a3a3a' }} | Use a token |
transform: scale(0.95) on click but no transition | Add transition: transform 150ms ease |
Hover-only states (no focus-visible) | Always pair hover with focus |
outline: none without replacement | Use :focus-visible { outline: 2px solid var(--color-brand); } |
Fixed width: 320px | Use max-width: 320px; width: 100% |
100vh on mobile breaks (browser chrome) | Use 100dvh or 100svh |
Animating width for a sliding menu | Animate transform: translateX |
Setting font-size: 14px everywhere | Use a type scale, set base on <html> and rem elsewhere |
position: absolute without position: relative parent | Always pair |
| Forgetting hover/focus/active/disabled | Run through all 4 states for every interactive |
Narration phrases
- "I'm using CSS custom properties for tokens so theme switching is a single class toggle on
<html> — no React re-render needed."
- "All animation is on
transform and opacity — those run on the compositor and don't trigger layout."
- "I added
prefers-reduced-motion because the hero animation isn't essential to the content."
- "I'm using container queries here because this card lives in both a 3-column grid and a sidebar — viewport breakpoints would lie."
- "The breakpoints are mobile-first; the only place I had to override down was the sticky nav at < 480px."