---
name: frontend-css
description: >-
Enforces modern CSS patterns and prevents legacy anti-patterns when writing
stylesheets. Use when generating CSS, styling components, writing media
queries, adding animations, or working with colors and layout.
---
# Modern CSS
Write CSS using native modern features. Every pattern below has baseline
cross-browser support (Chrome, Firefox, Safari, Edge) unless noted otherwise.
Prefer native CSS over preprocessors, libraries, and JavaScript workarounds.
All visual values (colors, spacing, typography, motion) must come from design
tokens defined in the `frontend-design-system` skill — never hardcode values
in component styles.
## Nesting
Use native CSS nesting. Do not add Sass, Less, or PostCSS nesting plugins.
```css
.card {
padding: var(--space-lg);
& .card__title {
font-size: var(--text-lg);
}
&:hover {
background: var(--color-bg-elevated);
}
@media (width < 600px) {
padding: var(--space-md);
}
}
```
**Maximum nesting depth: 3 levels.** Deep nesting creates specificity problems
and makes styles harder to override. If you need deeper nesting, flatten with
a new class — that's a signal the component should be broken apart.
## Container Queries
Use `@container` for component-level responsiveness. `@media` queries check
the viewport, but a component doesn't know if it's in a full-width layout or
a narrow sidebar. Container queries solve this.
**Reserve `@media` for page-level layout shifts only** (switching from
sidebar to stacked layout). Use `@container` for everything else.
```css
.card-wrapper {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
@container (width >= 400px) {
grid-template-columns: 200px 1fr;
}
}
```
The `container-type` must be on an ancestor, never on the element being
queried. The query measures the container's available space, not the
element's own width.
## :has() Selector
`:has()` enables parent-aware and sibling-aware styling without JavaScript
class toggling. It replaces the most common remaining use case for JS in
styling.
```css
.form-group:has(input:focus) {
border-color: var(--color-accent);
}
.field:has(input:not(:placeholder-shown)) .field__label {
transform: translateY(-100%) scale(0.8);
}
.card:has(img) {
grid-template-rows: 200px 1fr;
}
```
`:has()` is powerful but expensive to evaluate at scale. Avoid using it in
selectors that match many elements on the page (e.g., `*:has(...)` or
`div:has(...)`). Scope it to specific component classes.
## CSS Layers
Use `@layer` to control cascade priority explicitly. This eliminates
specificity wars and makes the order of `<link>` tags irrelevant.
Define the layer order once at the top of the main stylesheet:
```css
@layer reset, base, components, utilities;
@layer reset {
*, *::before, *::after { box-sizing: border-box; margin: 0; }
}
@layer base {
body { font-family: var(--font-sans); }
}
@layer components {
.btn { padding: var(--space-sm) var(--space-lg); }
}
@layer utilities {
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
```
Styles outside any `@layer` always beat layered styles. Never put component
styles outside a layer — it defeats the purpose of the cascade control.
## Scroll-Driven Animations
Use native scroll-driven animations instead of JavaScript scroll libraries.
Supported in Chrome, Edge, and Safari. Firefox is in active development
(Interop 2025/2026 focus area) — provide a static or IntersectionObserver
fallback for Firefox until it ships.
```css
@keyframes fade-in {
from { opacity: 0; translate: 0 2rem; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
```
**Fallback pattern**: elements using `animation-timeline: view()` should have
their final visual state as the default (opacity: 1, translate: 0), and the
animation should enhance by starting from the hidden state. This way,
unsupported browsers show the content normally — they just don't get the
scroll animation.
```css
.progress-bar {
animation: grow-width linear both;
animation-timeline: scroll(root);
}
@keyframes grow-width {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
```
## View Transitions
Use the View Transitions API for page-to-page and state-change animations.
For multi-page apps (MPA), opt in via meta tag and CSS:
```css
@view-transition {
navigation: auto;
}
```
Name elements that should animate between pages:
```css
.hero-image {
view-transition-name: hero;
}
```
Every `view-transition-name` must be unique on the page. Two elements with
the same name will break the transition. For lists, generate unique names
dynamically with custom properties or inline styles.
For single-page state changes, call `document.startViewTransition()` in JS
before updating the DOM.
## Entry Animations with @starting-style
Use `@starting-style` to animate elements on entry (when they first render
or transition from `display: none`). This replaces the common hack of
adding an animation class via `requestAnimationFrame`.
```css
dialog {
opacity: 1;
translate: 0 0;
transition: opacity var(--transition-base), translate var(--transition-base);
@starting-style {
opacity: 0;
translate: 0 1rem;
}
}
[popover] {
opacity: 1;
transition: opacity var(--transition-fast), display var(--transition-fast) allow-discrete;
@starting-style {
opacity: 0;
}
}
```
Pair with `allow-discrete` on the `transition` shorthand when transitioning
`display` or `overlay` properties.
## Modern Color
Use `oklch()` as the default color space. Its perceptually uniform lightness
means a value of 50% looks equally bright regardless of hue — making palette
scales predictable. See the `frontend-design-system` skill for the full
color token architecture.
```css
:root {
--color-primary: oklch(55% 0.2 250);
--color-primary-light: oklch(75% 0.15 250);
--color-primary-dark: oklch(35% 0.2 250);
}
```
Use `color-mix()` for dynamic variants:
```css
.btn:hover {
background: color-mix(in oklch, var(--color-primary) 85%, white);
}
```
Use `light-dark()` for theme-aware values without duplicating declarations:
```css
:root { color-scheme: light dark; }
.surface {
background: light-dark(var(--gray-50), var(--gray-900));
color: light-dark(var(--gray-900), var(--gray-50));
}
```
## Logical Properties
Use logical properties instead of physical directional properties. This
makes styles work in both LTR and RTL layouts without overrides.
| Physical (avoid) | Logical (use) |
|-------------------|---------------|
| `margin-left` | `margin-inline-start` |
| `margin-right` | `margin-inline-end` |
| `padding-top` | `padding-block-start` |
| `padding-bottom` | `padding-block-end` |
| `width` | `inline-size` |
| `height` | `block-size` |
| `top` | `inset-block-start` |
| `left` | `inset-inline-start` |
| `border-left` | `border-inline-start` |
| `text-align: left` | `text-align: start` |
Shorthand: `margin-inline`, `padding-block`, `inset` for symmetric values.
## Subgrid
Use `subgrid` when child elements need to align to an ancestor grid.
```css
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-xl);
}
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
}
```
This ensures card headers, bodies, and footers align across columns
regardless of content length. Without subgrid, each card's internal grid
is independent and row heights won't match.
## Anchor Positioning
Use CSS anchor positioning for tooltips, popovers, and floating UI instead
of JavaScript positioning libraries (Floating UI, Popper).
```css
.trigger {
anchor-name: --tooltip-anchor;
}
.tooltip {
position: fixed;
position-anchor: --tooltip-anchor;
inset-block-end: anchor(top);
inset-inline-start: anchor(center);
translate: -50% -0.5rem;
position-try-fallbacks: flip-block, flip-inline;
}
```
`position-try-fallbacks` handles cases where the tooltip would overflow the
viewport — it automatically flips the position. This replaces the most
complex part of JS positioning libraries.
## Selectors and Specificity
Prefer low-specificity selectors. A single class is the sweet spot — specific
enough to target the element, low enough to override easily.
- No ID selectors in stylesheets (`#header`)
- No deeply nested selectors (`.page .section .content .card .title`)
- No `!important` except in the `utilities` layer
- No qualifying classes with elements (`div.card`) — just `.card`
## Units
| Context | Unit | Rationale |
|---------|------|-----------|
| Font sizes | `rem` | Respects user's browser font size preference |
| Spacing / padding / margins | `rem` or tokens | Scales proportionally with font size |
| Line height | Unitless (`1.5`) | Inherits correctly to children |
| Border / outline widths | `px` | Sub-pixel rendering is expected |
| Media / container queries | Range syntax (`width >= 600px`) | Clearer than `min-width` |
| Zero values | No unit (`0`) | `0px` and `0rem` are identical noise |
| Viewport-relative | `dvh` / `dvw` | Accounts for mobile browser chrome |
## Anti-Patterns
**Never do these:**
- Use a preprocessor for nesting — native nesting is baseline supported
- Write `@media` queries for single-component responsiveness — use
`@container` queries
- Toggle parent classes from JavaScript for styling — use `:has()`
- Add `!important` outside of a utility layer — it starts specificity wars
that escalate until everything is `!important`
- Use `height: 100vh` on mobile — the mobile browser chrome makes `100vh`
taller than the visible area; use `100dvh`
- Import animation libraries for scroll effects — use `animation-timeline`
- Hardcode color values — use design tokens from `frontend-design-system`
- Use `rgb()` or `hsl()` for new colors — use `oklch()` for perceptual
uniformity
- Write physical properties (`left`, `right`, `top`, `bottom`) — use
logical properties for RTL compatibility
- Set `z-index: 9999` — use the z-index scale from `frontend-design-system`
- Use `float` for layout — use grid or flexbox
- Nest selectors beyond 3 levels — flatten with a new class