Vanilla CSS best practices — cascade, custom properties, layout decisions, modern selectors, responsive strategy, accessibility, and performance
npx skills add m10rten/typescript-bits --skill css-best-practicesskill.md · 487 lines~2.3kAuthored CSS for production. No frameworks, no utilities. Real cascade, real selectors, real performance.
Never use `!important`. It breaks the cascade and makes overrides harder.
Specificity tiers:
`p { }``.heading { }``#main { }` (rarely needed; prefer classes)Stack specificity intentionally. Low specificity wins by loading order; high specificity wins always.
/* ❌ Brittle override */
.button {
color: blue !important;
}
.primary {
color: red;
}
/* ✅ Layered intent */
@layer base {
.button {
color: blue;
}
}
@layer theme {
.button.primary {
color: red;
}
}Use `@layer` to organize intent:
`@layer base` - element defaults and resets`@layer utilities` - single-purpose rules`@layer components` - higher-level patternsLater layers override earlier ones, regardless of file order. Declare layer names once at the top.
Scoped, inherited, accessible to JavaScript. No build step.
:root {
--color-primary: oklch(0.6 0.15 240);
--spacing-unit: 1rem;
--font-family-sans: system-ui, sans-serif;
}
.card {
--card-padding: calc(var(--spacing-unit) * 1.5);
padding: var(--card-padding);
}Defaults & fallbacks:
/* Fallback if variable undefined */
color: var(--text-color, #333);
/* Multiple fallbacks */
font-family: var(--font-family-serif, Georgia, serif);Theming without extra markup:
:root {
--bg: white;
--text: #222;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--text: #ddd;
}
}
body {
background: var(--bg);
color: var(--text);
}Use `@property` for non-string values with transitions:
@property --rotation {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.spinner {
animation: spin 2s linear infinite;
}
@keyframes spin {
to {
--rotation: 360deg;
}
}Flexbox: one dimension (row or column), content-driven, wrapping.
.navbar {
display: flex;
gap: 1rem;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}Grid: two dimensions, structure-first, explicit placement.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
/* Auto-flow for responsive without media queries */
.grid-auto {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}Subgrid for nested alignment:
.card {
display: grid;
grid-template-columns: auto 1fr;
}
.card-body {
display: grid;
grid-column: 1 / -1;
}
/* Aligns with parent's columns */
.card-body-inner {
display: contents;
}Intrinsic sizing without breakpoints:
/* Scale content naturally */
.container {
width: min(90vw, 1200px);
}
/* Flexible, responsive widths */
.sidebar {
width: clamp(250px, 25vw, 400px);
}
/* Two-column that wraps at smaller sizes */
.columns {
display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
}`:has()` - parent selector
/* Style label if checkbox inside is checked */
label:has(input:checked) {
font-weight: bold;
}
/* Style section if it has a heading */
section:has(h2) {
margin-top: 2rem;
}`:is()` and `:where()` - group selectors (`:where()` has 0 specificity)
/* Instead of: h1, h2, h3, h4, h5, h6 { } */
:is(h1, h2, h3, h4, h5, h6) {
line-height: 1.2;
}
/* :where() for resets (doesn't increase specificity) */
:where(ul, ol) {
list-style: none;
}Mobile-first: base styles apply everywhere; larger screens get adjustments.
.grid {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}Container queries (not media queries) for component-level responsiveness:
@container (min-width: 300px) {
.card {
display: grid;
grid-template-columns: 1fr 1fr;
}
}Use media queries for global viewport breakpoints; container queries for component behavior that should adapt to available space.
Replace physical directions with logical ones. They respond to writing direction (LTR, RTL).
/* ❌ Physical */
.box {
margin-left: 1rem;
padding-right: 2rem;
}
/* ✅ Logical */
.box {
margin-inline-start: 1rem;
padding-inline-end: 2rem;
}| Physical | Logical | Use case |
|---|---|---|
`left` | `inline-start` | Horizontal spacing |
`right` | `inline-end` | Horizontal spacing |
`top` | `block-start` | Vertical spacing |
`bottom` | `block-end` | Vertical spacing |
`width` | `inline-size` | Width (respects flow) |
`height` | `block-size` | Height (respects flow) |
Relative units for scaling:
/* rem scales with root font-size */
.heading {
font-size: 2rem;
}
/* em scales with element's font-size */
.box {
padding: 1.5em;
}
/* dvh/svh avoid mobile address bar issues */
.hero {
min-height: 100dvh;
}
/* Avoid px for font; use rem or em */
body {
font-size: 1rem; /* 16px */
}Modern color with oklch:
/* oklch is perceptually uniform, works in dark mode */
--primary: oklch(0.6 0.15 240);
/* color-mix for computed tints/shades */
--primary-light: color-mix(in oklch, var(--primary) 70%, white);
--primary-dark: color-mix(in oklch, var(--primary) 70%, black);Prefer `light-dark()`:
:root {
color-scheme: light dark;
--bg: light-dark(white, #1a1a1a);
--text: light-dark(#222, #ddd);
}
body {
background: var(--bg);
color: var(--text);
}Fallback if `light-dark()` not supported:
:root {
--bg: white;
--text: #222;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--text: #ddd;
}
}
body {
background: var(--bg);
color: var(--text);
}Focus visible:
button:focus-visible {
outline: 3px solid var(--focus-color);
outline-offset: 2px;
}
/* Hide focus for mouse users, show for keyboard */
button:focus:not(:focus-visible) {
outline: none;
}Minimum touch target (48x48px):
button {
min-width: 48px;
min-height: 48px;
}Respect motion preferences:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Color contrast: Ensure text-to-background contrast meets WCAG AA (4.5:1 for small text).
Animate only compositor-friendly properties:
/* ✅ GPU accelerated */
.slide {
animation: slide 0.3s ease-out;
}
@keyframes slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* ❌ Triggers reflow */
.slide {
animation: slide 0.3s ease-out;
}
@keyframes slide {
from {
left: -100px;
}
to {
left: 0;
}
}Safe properties: `transform`, `opacity`, `filter`. Avoid: `width`, `height`, `top`, `left`, `margin`, `padding`.
Reduce layout work:
/* Will-change: only on elements that actually animate */
.animated {
will-change: transform;
}Content visibility for long pages:
/* Skip rendering off-screen content */
.section {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}Keep CSS close to usage. Use consistent naming conventions.
/* BEM for clarity */
.card {
/* block */
}
.card__header {
/* element */
}
.card--featured {
/* modifier */
}Use `@layer` to separate concerns, not files.
@layer base {
/* resets */
}
@layer components {
/* .card, .button */
}
@layer utilities {
/* single-purpose */
}| Mistake | Fix |
|---|---|
Using `!important` to override | Rely on cascade; use `@layer` |
| Hardcoded colors instead of variables | Use custom properties for theming |
| Media queries for component responsiveness | Use container queries |
| Physical properties (left/right) globally | Use logical properties for i18n support |
| Animating non-GPU properties | Transform and opacity only; avoid width/height/position |
| Forgetting focus-visible on interactive elements | Always include focus states |
| No fallbacks for custom properties | Provide sensible defaults |
| Mixing px and rem inconsistently | Use rem for scaling; px sparingly |
Assuming dark mode without `color-scheme` | Set `color-scheme: light dark` at root |