DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning CSS As If You Built It Yourself

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" />
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

: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:

  1. Origin and layer (browser default < user style < author style; @layer ordering)
  2. !important (avoid; it inverts the order)
  3. 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
  4. Source order (last one wins, when everything else ties)

Two senior level rules:

  • Avoid !important unless you are overriding a third party widget you cannot edit.
  • Keep specificity flat. Stick to one class per rule. .button.is-primary is fine. .page .container .card .button is 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; } }
Enter fullscreen mode Exit fullscreen mode

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     │  │   │
│   │   └────────────────────────┘  │   │
│   └──────────────────────────────┘   │
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode
  • relative keeps the element in flow but lets you nudge it. Mostly used to anchor absolute children.
  • absolute pulls the element out of flow and positions it relative to the nearest positioned ancestor.
  • fixed positions relative to the viewport. Stays put as you scroll.
  • sticky acts like relative until 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.

  • px is fine for borders, hairlines, and fixed sized icons.
  • rem for typography and spacing. 1rem = the root font size (usually 16px). Scales with user preferences.
  • em for spacing relative to the current element's font size. Useful inside components.
  • % is relative to the parent.
  • vw, vh are 1% of the viewport. dvh (dynamic viewport height) handles mobile address bars correctly. Prefer dvh for full screen heroes.
  • ch is the width of the "0" character. Great for capping line length: max-width: 65ch.
  • fr for grid columns and rows.
  • % in grid is brittle, use fr.

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%); }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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); }
Enter fullscreen mode Exit fullscreen mode

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); }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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; } }
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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 */ }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reset / normalize: kill default margins, set box-sizing: border-box, normalize fonts.
  2. Base: typography, links, focus styles, page level rules.
  3. Components: .button, .card, .input. One class, one job.
  4. 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; }
Enter fullscreen mode Exit fullscreen mode

This single block prevents an enormous number of beginner bugs.

A peek under the hood

What really happens when the browser styles your page:

  1. The browser parses your CSS into the CSSOM (a tree of rules and selectors).
  2. It walks every node in the DOM and matches selectors against it.
  3. For each property, it picks the winning value using cascade and specificity rules.
  4. It computes inherited values, then resolved values (turning em into px, var(--x) into the real value).
  5. It runs layout, calculating sizes and positions.
  6. It runs paint, drawing pixels into layers.
  7. It runs composite, stitching the layers onto the screen.
  8. Future state changes go through this pipeline again. Animations on transform and opacity skip 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-box globally 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-visible instead of :focus so mouse users do not see outlines they did not ask for, but keyboard users still do.
  • Use gap for 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 transform and opacity. Avoid top, left, width.
  • Respect prefers-reduced-motion.
  • Use :has() and container queries. They have replaced workarounds.
  • Use @layer to 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)