If you have ever inherited a codebase where two screens look the same but their styles live in five different files, with !important peppered everywhere and class names like card-2-blue-final-v2, you have met CSS at its messiest. CSS lets a small team of friendly people produce styles that grow into a tangled jungle within a year.
Done well, CSS is one of the most expressive layout systems ever invented. Done poorly, it is the reason you are working on Saturday.
That is the gap CSS fills, and the discipline it asks for.
What is CSS, really
Think of CSS as the wardrobe and the floor plan for your HTML. HTML provides the bones. CSS decides how things look (colors, fonts, spacing, shadows) and where they go (rows, columns, stacks, grids). It is declarative: you describe the result, the browser figures out the layout, paint, and animation.
Three loud rules define the language:
- The cascade decides who wins. Many rules can target the same element. CSS has a clear order for picking the winner: origin, layer, importance, specificity, source order.
- Selectors target nodes, properties set values. Every rule is "find these elements, set these properties".
- Layout flows from the box model. Every element is a box: content, padding, border, margin. Positioning, flex, and grid are layered on top.
That is the whole vibe.
Let's pretend we are building one
We want a way to style HTML without inlining style="" everywhere. We want layouts that respond to screen size. We want themeing, animations, and responsive design without inventing a new language for each one. We will call it CSS (Cascading Style Sheets).
For our running example, we are styling a tiny profile card for a person, with a name, an avatar, and a few buttons.
Decision 1: Three places to write CSS, one of them is right
<!-- inline (avoid) -->
<p style="color: red;">hi</p>
<!-- in the document (fine for tiny pages) -->
<style>
p { color: red; }
</style>
<!-- linked stylesheet (the real default) -->
<link rel="stylesheet" href="/styles.css" />
Linked stylesheets cache, parallelize with the HTML download, and keep style separate from markup. Inline styles win specificity battles you do not want to win.
A real .css file is just a list of rules:
selector {
property: value;
another: value;
}
That is the entire syntax. The hard part is everything inside.
Decision 2: Selectors, the part everyone half knows
/* by tag */
p { ... }
/* by class (the workhorse) */
.card { ... }
/* by id (rarely, hard to override) */
#header { ... }
/* by attribute */
input[type="email"] { ... }
/* combinators */
.card .title { ... } /* descendant */
.card > .title { ... } /* direct child */
.card + .card { ... } /* next sibling */
.card ~ .card { ... } /* any later sibling */
/* pseudo classes (state) */
a:hover { ... }
button:focus-visible { ... }
input:invalid { ... }
li:nth-child(odd) { ... }
input:disabled { ... }
/* pseudo elements (parts) */
p::first-line { ... }
.card::before { content: ""; }
::placeholder { ... }
::selection { background: yellow; }
/* lists of selectors */
h1, h2, h3 { ... }
The four senior level moves you should reach for:
/* :is() flattens long selector lists */
:is(h1, h2, h3) .title { ... }
/* :where() does the same, but with zero specificity (great for resets) */
:where(button, [type="button"]) { all: unset; }
/* :not() excludes */
li:not(.featured) { ... }
/* :has() (the parent selector everyone wanted for 20 years) */
.card:has(img) { padding: 0; }
form:has(:invalid) button[type="submit"] { opacity: 0.5; }
:has() shipped in every modern browser in 2023 and changed how we write CSS. You can finally style a parent based on its children.
Decision 3: Specificity and the cascade, the rules that decide who wins
When two rules target the same element, CSS picks one in this order:
-
Origin and layer (browser default < user style < author style;
@layerordering) -
!important(avoid; it inverts the order) -
Specificity (a, b, c, d):
- inline
style=""= 1, 0, 0, 0 - id = 0, 1, 0, 0
- class, attribute, pseudo class = 0, 0, 1, 0
- tag, pseudo element = 0, 0, 0, 1
- inline
- Source order (last one wins, when everything else ties)
Two senior level rules:
-
Avoid
!importantunless you are overriding a third party widget you cannot edit. -
Keep specificity flat. Stick to one class per rule.
.button.is-primaryis fine..page .container .card .buttonis a future migraine.
@layer is the modern fix for the specificity arms race. You define ordered layers and put rules into them. Lower layers always lose to higher layers, regardless of selector specificity.
@layer reset, base, components, utilities;
@layer reset { /* normalize */ }
@layer base { body { font-family: system-ui; } }
@layer components { .card { padding: 1rem; } }
@layer utilities { .text-center { text-align: center; } }
Decision 4: The box model, what every element really is
Every element on the page is a box made of four parts:
┌────────────── margin ──────────────┐
│ ┌────────── border ──────────┐ │
│ │ ┌────── padding ──────┐ │ │
│ │ │ content area │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
The trap: by default, width only sets the content width, and padding and border are added on top. Most teams set this once and never look back:
*, *::before, *::after { box-sizing: border-box; }
Now width includes padding and border. Sane.
A note on margins: vertical margins between block elements collapse (the bigger one wins, they do not add up). It is one of the original CSS quirks. Learn it once, then never get surprised.
Decision 5: Flow, then Flexbox, then Grid
Layout in CSS evolved through three eras. You will use all three, often in the same component.
Normal flow
The default. Block elements stack vertically and take full width. Inline elements flow horizontally and wrap. This is what happens with no layout CSS at all. Trust it more than you think.
Flexbox, for one dimensional layouts
A row or a column where children share space.
.toolbar {
display: flex;
gap: 1rem; /* the modern way to space children */
align-items: center; /* cross axis */
justify-content: space-between; /* main axis */
}
.toolbar > button {
flex: 1; /* share remaining space */
}
The mental model: flex-direction picks the main axis. justify-content aligns along it. align-items aligns across it. gap puts space between children without messy margins.
Grid, for two dimensional layouts
Rows and columns at the same time.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
That single rule is the famous "responsive grid that fills with as many columns as fit". 1fr means "one fraction of the available space". auto-fill plus minmax adjusts the column count to the screen.
The senior level move is named areas for entire page layouts:
.app {
display: grid;
grid-template-areas:
"header header"
"side main"
"footer footer";
grid-template-columns: 240px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
.app > header { grid-area: header; }
.app > nav { grid-area: side; }
.app > main { grid-area: main; }
.app > footer { grid-area: footer; }
The rule of thumb: grid for layout, flex for components inside layout. They compose beautifully.
Decision 6: Position, when you actually need it
Most of the time, layout flow + flex + grid is enough. Sometimes you need a specific element to escape:
.tooltip { position: absolute; top: 0; left: 0; }
.modal { position: fixed; inset: 0; }
.header { position: sticky; top: 0; }
.card { position: relative; }
-
relativekeeps the element in flow but lets you nudge it. Mostly used to anchorabsolutechildren. -
absolutepulls the element out of flow and positions it relative to the nearest positioned ancestor. -
fixedpositions relative to the viewport. Stays put as you scroll. -
stickyacts likerelativeuntil you scroll past, then pins. Magical for sticky headers.
Modern shortcut: inset: 0 is top: 0; right: 0; bottom: 0; left: 0.
Decision 7: Units, the trap that takes years to learn
CSS has a lot of units. Pick the right one.
-
pxis fine for borders, hairlines, and fixed sized icons. -
remfor typography and spacing.1rem= the root font size (usually 16px). Scales with user preferences. -
emfor spacing relative to the current element's font size. Useful inside components. -
%is relative to the parent. -
vw,vhare 1% of the viewport.dvh(dynamic viewport height) handles mobile address bars correctly. Preferdvhfor full screen heroes. -
chis the width of the "0" character. Great for capping line length:max-width: 65ch. -
frfor grid columns and rows. -
%in grid is brittle, usefr.
The functions you will reach for daily:
.title {
/* fluid font size: at least 1.5rem, ideally 4vw, never more than 3rem */
font-size: clamp(1.5rem, 4vw, 3rem);
}
.gap { gap: min(2rem, 5vw); }
.col { width: max(280px, 25%); }
clamp(min, ideal, max) is the magic for fluid typography and spacing. No media queries needed for most cases.
Decision 8: Colors, the modern way
Use what is comfortable, but know the modern options:
.box {
background: #ff6b6b; /* hex */
background: rgb(255 107 107); /* modern syntax, no commas */
background: rgb(255 107 107 / 0.5); /* with alpha */
background: hsl(0 80% 70%); /* hue saturation lightness */
background: oklch(70% 0.15 20); /* perceptually uniform */
background: color-mix(in oklch, white 30%, blue);
}
Two senior level recommendations:
- Use HSL or OKLCH for design tokens. Adjusting "the same color but a bit lighter" is one number.
- OKLCH is now widely supported and produces colors that look the same brightness across the wheel. Great for accessible palettes and dark modes.
For dark mode:
:root {
--bg: white;
--text: #111;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--text: #eee;
}
}
body { background: var(--bg); color: var(--text); }
Use a light-dark() function (newer browsers) to write both in one line:
:root { color-scheme: light dark; }
body { background: light-dark(white, #111); color: light-dark(#111, #eee); }
Decision 9: Custom properties (CSS variables), the design token system you already have
:root {
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 2rem;
--radius: 0.5rem;
--shadow: 0 4px 12px rgb(0 0 0 / 0.08);
--color-bg: white;
--color-fg: #111;
--color-brand: #5b8def;
}
.card {
background: var(--color-bg);
color: var(--color-fg);
padding: var(--space-3);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
Variables are inherited and can be changed at runtime, including by JavaScript. They are the foundation of theming, dark mode, and design systems.
Two power moves:
/* component scoped variables */
.button {
--btn-bg: var(--color-brand);
background: var(--btn-bg);
}
.button:hover {
--btn-bg: color-mix(in oklch, var(--color-brand) 80%, white);
}
/* fallback if not set */
color: var(--color-fg, black);
Decision 10: Responsive design, mobile first
Always design for the smallest screen first, then add larger screen rules with media queries. The CSS for small screens is simpler, you avoid having to "undo" desktop styles, and the build is friendlier to phones.
.gallery {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 640px) { .gallery { grid-template-columns: 1fr 1fr; } }
@media (min-width: 960px) { .gallery { grid-template-columns: 1fr 1fr 1fr; } }
@media (min-width: 1280px) { .gallery { grid-template-columns: 1fr 1fr 1fr 1fr; } }
The newer move is container queries. Style based on the size of the container, not the viewport. Game changing for component libraries:
.sidebar { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: grid; grid-template-columns: auto 1fr; }
}
The card now responds to the sidebar's width, not the page's. A card placed in a wide context can look different from the same card in a narrow column. No more "the design changes when the parent layout changes".
Other useful media queries:
@media (prefers-reduced-motion: reduce) { /* skip animations */ }
@media (prefers-color-scheme: dark) { /* dark theme */ }
@media (hover: hover) { .card:hover { ... } } /* skip hover on touch */
@media (orientation: landscape) { ... }
@media (max-width: 640px) { /* mobile only */ }
Decision 11: Transitions and animations
For state changes, use transitions:
.button {
background: var(--color-brand);
transition: background 150ms ease, transform 150ms ease;
}
.button:hover {
background: color-mix(in oklch, var(--color-brand) 80%, white);
transform: translateY(-1px);
}
For repeating or complex motion, use keyframes:
@keyframes pulse {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.05); opacity: 0.7; }
}
.notification {
animation: pulse 1.2s ease-in-out infinite alternate;
}
Senior level rule: animate transform and opacity whenever possible. They are GPU friendly and do not trigger layout. Animating width, top, or margin is expensive and janky on phones.
Always respect users:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
For truly fancy moves, View Transitions API (newer) lets you animate between full DOM states with a single line: document.startViewTransition(() => { ... }). Single page apps and even simple multi page transitions become beautiful for almost no work.
Decision 12: Modern CSS you should know in 2026
A short tour of recent features that have shipped widely:
/* native nesting (no preprocessor needed) */
.card {
padding: 1rem;
&:hover { background: #f6f6f6; }
& .title { font-weight: bold; }
}
/* :has() the parent selector */
form:has(:invalid) button { opacity: 0.5; }
/* container queries */
.aside { container-type: inline-size; }
@container (min-width: 400px) { ... }
/* logical properties */
.card {
margin-inline: auto; /* left/right in LTR, right/left in RTL */
padding-block: 1rem; /* top + bottom */
border-inline-start: 4px solid var(--brand); /* "left" in LTR */
}
/* aspect-ratio */
.video { aspect-ratio: 16 / 9; width: 100%; }
/* scroll snap */
.gallery { scroll-snap-type: x mandatory; overflow-x: auto; }
.gallery > .item { scroll-snap-align: start; }
/* accent color (free theming for native controls) */
:root { accent-color: var(--color-brand); }
/* color-mix for dynamic colors */
.button:hover { background: color-mix(in oklch, var(--brand) 85%, white); }
/* subgrid for nested grids that share columns */
.row { display: grid; grid-template-columns: subgrid; }
These are not "nice to haves" anymore. They replace whole classes of preprocessor tricks and JS hacks.
Decision 13: Reset, base, components, utilities
A repeatable structure for any CSS codebase, big or small:
-
Reset / normalize: kill default margins, set
box-sizing: border-box, normalize fonts. - Base: typography, links, focus styles, page level rules.
-
Components:
.button,.card,.input. One class, one job. -
Utilities (optional): single purpose helpers like
.text-center,.mt-4. Tailwind has popularized a "utilities first" version of this.
A modern minimal reset everyone copies (Andy Bell style):
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html, body { height: 100%; }
body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
img, picture, video, canvas, svg { display: block; max-width: 100%; }
input, button, textarea, select { font: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
This single block prevents an enormous number of beginner bugs.
A peek under the hood
What really happens when the browser styles your page:
- The browser parses your CSS into the CSSOM (a tree of rules and selectors).
- It walks every node in the DOM and matches selectors against it.
- For each property, it picks the winning value using cascade and specificity rules.
- It computes inherited values, then resolved values (turning
emintopx,var(--x)into the real value). - It runs layout, calculating sizes and positions.
- It runs paint, drawing pixels into layers.
- It runs composite, stitching the layers onto the screen.
- Future state changes go through this pipeline again. Animations on
transformandopacityskip layout and paint, which is why they feel smooth.
Two consequences for senior work:
- Long selectors are slow to match on huge DOMs. Keep them short and class based.
- Layout thrashing in JavaScript (reading layout, then writing styles, then reading again in a loop) is the single biggest source of jank. Batch reads, then batch writes.
Tiny tips that will save you later
- Set
box-sizing: border-boxglobally on day one. - Use a tiny modern reset. Do not start from raw browser defaults.
- Stick to one class per rule. Avoid id selectors.
-
Use
:focus-visibleinstead of:focusso mouse users do not see outlines they did not ask for, but keyboard users still do. -
Use
gapfor flex and grid. No more margin trickery. -
Use
clamp()for fluid typography. Skip ten media queries. - Use custom properties for tokens. Theme switching becomes free.
- Default to mobile first.
-
Animate
transformandopacity. Avoidtop,left,width. - Respect
prefers-reduced-motion. -
Use
:has()and container queries. They have replaced workarounds. -
Use
@layerto keep specificity manageable in big codebases. - Test with the keyboard, screen reader, and the smallest phone you have.
Wrapping up
So that is the whole story. We needed a way to style HTML without scattering inline styles or writing imperative drawing code. We invented CSS, a declarative language where you describe what you want and the browser does layout, paint, and animation.
We learned to lean on the cascade instead of fighting it, to keep specificity flat, to pick the right unit, to use Flex and Grid for layout, and to embrace modern features (:has(), container queries, @layer, custom properties, clamp(), OKLCH, view transitions) that have replaced years of hacks.
Once that map is in your head, CSS stops feeling like a pile of mysterious overrides and starts feeling like the elegant, expressive system it actually is. You stop fighting it and start composing with it.
Happy styling, and may your !important count be zero.
Top comments (0)