The Cascade, Specificity & the Box Model
How the browser decides which CSS rule wins (origin, @layer, specificity, order), how inheritance and box-sizing work, and why z-index sometimes seems broken.
The fundamentals that separate good frontend engineers: semantics, accessibility, the cascade and layout.
How the browser decides which CSS rule wins (origin, @layer, specificity, order), how inheritance and box-sizing work, and why z-index sometimes seems broken.
box-sizing: border-box changes.easyEvery element is laid out as a rectangular box made of four layers, from the inside out: content, padding, border and margin. Padding and border belong to the element's box; margin is transparent space outside the border that separates it from its neighbours.
By default box-sizing: content-box applies, so width and height size only the content area and padding and border are added on top. A box with width: 200px, 20px padding and a 1px border actually takes up 242px horizontally.
With box-sizing: border-box, width and height include the content, padding and border, so that box is exactly 200px wide and the content area shrinks to make room. Margins are never included in either mode.
Most codebases apply border-box globally because it makes sizing predictable, especially when you mix percentage widths with fixed padding.
*, *::before, *::after {
box-sizing: border-box;
}content-box: width excludes padding and borderborder-box: width includes padding and border, never marginborder-box reset makes sizing predictableLikely follow-up: Why do resets also target ::before and ::after? · What happens in border-box if padding plus border exceed the declared width?
Semantic HTML means choosing elements for their meaning, not their appearance: <nav> for navigation, <main> for the primary content, <button> for actions, <h1>–<h6> for a real heading hierarchy, <ul> for lists and <table> for tabular data, instead of generic <div>s and <span>s styled to look right.
It matters because:
<button> come with focus, keyboard activation and a role for free.A <div onclick> may look like a button, but it isn't focusable or keyboard-operable unless you rebuild all of that by hand.
Likely follow-up: What would you need to add to make a <div> behave like a button?
When several declarations set the same property on an element, the cascade picks a winner by comparing, in order: origin and importance (user-agent, user and author styles, with !important reversing their order), shadow DOM context, inline style attributes, cascade layers, then specificity, and finally order of appearance (the last one wins).
Specificity is compared as three columns (A, B, C):
Columns are compared left to right, so one ID beats any number of classes: #nav a (1,0,1) beats .menu .item a (0,2,1). The universal selector * and combinators add nothing; :where() counts as zero, while :is(), :not() and :has() take the specificity of their most specific argument.
Inline styles aren't a specificity column: they beat any selector in normal author styles, and only !important declarations override them.
/* <p class="text"> inside .card inside #main */
#main p { color: red; } /* (1,0,1) wins */
.card .text { color: blue; } /* (0,2,0) */
p.text { color: green; } /* (0,1,1) */* and combinators add nothing; :where() is zeroLikely follow-up: How do you override a style without increasing specificity? · What is the specificity of :is(#main, .card) p?
Flexbox is one-dimensional: it lays items out along a single axis, a row or a column, and distributes space based on the items' content sizes. It's ideal for components: navbars, toolbars, button groups, centering, or a row of tags that should wrap.
Grid is two-dimensional: you define rows and columns on the container and place items into that structure, so things line up in both directions. It's ideal for page layouts, card galleries, forms with aligned labels, and anything where the layout should drive the content rather than the other way round.
A useful rule of thumb: Flexbox is content-out, Grid is layout-in. They combine well, for example a Grid page layout with Flexbox inside each area. Both support gap and the same box alignment properties (justify-content, align-items, align-self), and subgrid lets nested grids align to their parent's tracks.
gap and alignment propertiesLikely follow-up: How would you build a card grid that wraps without any media queries?
Modern options:
display: grid; place-items: center; on the parent centers the child on both axes.display: flex; justify-content: center; align-items: center; on the parent.margin: auto on a child of a flex or grid container absorbs the free space on all sides.position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); works when the element's size is unknown, e.g. for overlays. With a known width and height you can use inset: 0; margin: auto; instead.For horizontal centering only, use margin-inline: auto on a block with a set width, or text-align: center for inline content. Setting line-height equal to the height vertically centers a single line of text, but it breaks as soon as the text wraps.
.parent {
display: grid;
place-items: center;
min-height: 100vh;
}place-items: center on the parentjustify-content and align-items set to centermargin: auto on a flex or grid childtranslate(-50%, -50%) for unknown sizesmargin-inline: auto for horizontal block centeringposition values static, relative, absolute, fixed and sticky.easytop and left have no effect.static) or has a transform, filter or similar; otherwise the initial containing block.transform, filter or perspective becomes its containing block instead.relative until it crosses a threshold such as top: 0 within its nearest scroll container, then sticks until its parent scrolls out of view. It needs an offset, and an ancestor with overflow: hidden or auto becomes the scroll container it sticks within, a common reason it "doesn't work".relative keeps its space and is offset from its normal positionabsolute leaves the flow; placed against nearest positioned ancestorfixed is relative to the viewport unless an ancestor has transformsticky acts relative, then sticks within its scroll containerLikely follow-up: Why might position: sticky not stick? · Why would a position: fixed modal move when its parent scrolls?
<script>, async, defer and type="module"?mid<script src>: the parser stops, the script is downloaded and executed, then parsing continues. It blocks everything below it, which is why scripts used to go at the end of <body>.DOMContentLoaded, in document order. Best for scripts that need the DOM or depend on each other.import. Adding async makes it run as soon as it and its dependencies have loaded.For classic scripts, async and defer only apply to external files (src); on inline module scripts, async does work. If both are present, async wins.
<script src="legacy.js"></script> <!-- blocks parsing -->
<script src="app.js" defer></script> <!-- after parsing, in order -->
<script src="analytics.js" async></script> <!-- whenever it arrives -->
<script type="module" src="main.js"></script> <!-- deferred by default -->defer: parallel download, runs after parsing, in orderasync: parallel download, runs when ready, order not guaranteedLikely follow-up: Why do scripts placed after a stylesheet sometimes wait for that stylesheet?
<div>, <p>, <h1>, <ul>, <section>) start on a new line and by default stretch to the full width of their container. width, height, margins and padding all apply.<span>, <a>, <strong>, <em>) flow within a line of text and are only as wide as their content. width and height are ignored, and vertical margins don't push other lines away; vertical padding is painted but doesn't change the line's height.width, height and vertical margins apply. It's handy for badges or buttons within a line.These are CSS display behaviours, so any element can be switched. Replaced elements such as <img> are inline by default but still respect width and height. A classic gotcha: whitespace between inline-block elements in the HTML renders as a small gap.
width and height ignoredinline-block: sits in the line but accepts size and marginsLikely follow-up: Why do inline-block items sometimes have a small gap between them?
display: none, visibility: hidden and opacity: 0.easydisplay, a child can set visibility: visible to reappear. It can be transitioned, which is why it's often paired with opacity for fade-outs.To hide something visually but keep it available to screen readers, none of these is right: use a "visually hidden" utility class.
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}display: none: no space, no rendering, not in the accessibility treevisibility: hidden: keeps space, not interactive, children can overrideopacity: 0: keeps space, still clickable, focusable and announcedpx, em, rem, %, vw/vh and ch. When would you use each?easyfont-size (for font-size itself, the parent's). It compounds when nested, which is handy for padding that scales with a component's text but can surprise you.html) font size. Predictable, and it respects the user's browser font-size setting, so it's the usual choice for font sizes and spacing.padding and margin percentages also use the containing block's width.100vh can be taller than the visible area while browser toolbars are showing; dvh (dynamic), svh (small) and lvh (large) address that.max-width: 65ch.em is relative to the element font size and compoundsrem is relative to the root font size% depends on the property, usually the containing blockdvh, svh and lvh fix mobile 100vh issuesch suits line-length limitsLikely follow-up: Why is rem better than px for font sizes from an accessibility point of view?
A pseudo-class (single colon) selects an existing element based on a state or position that a simple selector can't express: :hover, :focus-visible, :checked, :disabled, :first-child, :nth-child(2n), :not().
A pseudo-element (double colon) targets a part of an element, or generates a box that doesn't exist in the DOM: ::before and ::after (generated content, which only render when content is set), ::first-line, ::first-letter, ::placeholder, ::selection, ::marker, ::backdrop.
The double colon was introduced to tell the two apart; browsers still accept the single-colon form for the original four (:before, :after, :first-line, :first-letter). In specificity, pseudo-classes count like classes and pseudo-elements like type selectors.
Generated content can't be selected from JavaScript and should be decorative rather than carry essential meaning. ::before and ::after generally don't work on replaced elements such as <img> and <input>.
a:hover { text-decoration: underline; }
.required::after {
content: " *";
color: crimson;
}::before and ::after need content to render<!DOCTYPE html> do, and what happens if you leave it out?easyThe doctype must be the first thing in an HTML document, and its only job today is to make the browser render the page in standards mode. <!DOCTYPE html> is the short HTML5 form; it's case-insensitive and doesn't reference a DTD.
If it's missing, or you use certain legacy doctypes, browsers switch to quirks mode, which emulates old browser behaviour so that pages from the 1990s still render as their authors expected. In quirks mode, for example, unitless lengths like width: 100 are accepted, hex colors without # work, and percentage heights and line heights are calculated differently. Your modern CSS can then behave unexpectedly.
Some transitional doctypes trigger limited-quirks ("almost standards") mode, which mainly changes how images in table cells are laid out.
You can check the mode with document.compatMode: "CSS1Compat" means standards mode and "BackCompat" means quirks mode.
document.compatModez-index work, and what is a stacking context? Why might z-index: 9999 fail to bring an element to the front?hardz-index sets the stacking order of positioned elements (anything other than position: static) and of flex and grid items. Higher values paint in front, but values are only compared within the same stacking context.
A stacking context is an isolated group: its children are stacked relative to each other, then the whole group is painted as one unit at its own level in the parent context. Common triggers:
z-index other than auto (fixed and sticky always create one)z-index other than autoopacity below 1, transform, filter, clip-path, mask, mix-blend-modeisolation: isolate, contain: paint, or will-change naming one of these propertiesSo a child with z-index: 9999 inside a parent context at z-index: 1 can never appear above that parent's sibling at z-index: 2. Fix the ancestor's stacking level, remove the accidental context, or render the element at the top of the document, for example with a portal or the top layer (<dialog>, popover).
.header { position: relative; z-index: 2; }
.main { position: relative; z-index: 1; }
/* Can never cover .header: it is trapped in .main's stacking context */
.main .tooltip { position: absolute; z-index: 9999; }z-index applies to positioned elements and flex/grid itemsz-index, opacity below 1, transform, isolationLikely follow-up: What does isolation: isolate do, and when would you use it?
display: none elements are left out; pseudo-elements are included.Optimizing the path means fewer and smaller render-blocking resources: inline critical CSS, defer scripts, preload late-discovered critical assets, and reduce the bytes needed for the first render.
Likely follow-up: Why is CSS render-blocking but not parser-blocking?
width, height, margin, padding, top/left or font size, by adding or removing DOM nodes, or by resizing the window. It can affect descendants, siblings and ancestors, so it's the most expensive step.color, background-color, visibility or box-shadow change. Cheaper, but still main-thread work.transform, opacity on a layer) can often skip both.Layout thrashing happens when JavaScript interleaves writes and reads: after a style change, reading offsetHeight, getBoundingClientRect() or scrollTop forces the browser to run layout synchronously, and doing that in a loop runs layout many times per frame. Avoid it by batching all reads first and then all writes, scheduling writes with requestAnimationFrame, and animating transform and opacity instead of layout properties.
transform and opacityResponsive design means one codebase that adapts to any screen: fluid layouts (Flexbox, Grid, percentages, fr), flexible media (max-width: 100% on images, srcset), relative units, and media queries where the layout genuinely needs to change. The viewport meta tag is required so mobile browsers don't lay the page out at a fake desktop width.
Mobile-first means writing the base styles for small screens and layering enhancements with min-width queries as space grows. The base CSS stays simple, small screens don't have to undo desktop rules, and it forces you to prioritize content. Desktop-first is the opposite, using max-width queries.
Choose breakpoints where the content breaks, not for specific devices. Intrinsic techniques (auto-fit grids, clamp() for fluid type) and container queries reduce how many breakpoints you need. You can also query capabilities and preferences, such as (hover: hover) or (prefers-reduced-motion: reduce).
.grid { display: grid; gap: 1rem; }
@media (min-width: 48em) {
.grid { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 64em) {
.grid { grid-template-columns: repeat(3, 1fr); }
}min-width queries<meta name="viewport" content="width=device-width, initial-scale=1"> do, and why is it needed?easyWithout it, mobile browsers assume the page was designed for desktop: they lay it out on a wide virtual viewport (typically around 980px) and zoom out to fit the screen. Text ends up tiny and your media queries see a desktop-sized width, so the responsive styles never apply.
width=device-width sets the layout viewport to the device's width in CSS pixels, so media queries and percentage widths match the actual screen.initial-scale=1 sets the initial zoom level to 100%.It goes in the <head> and is required for responsive design to work on phones. Avoid adding user-scalable=no or maximum-scale=1: blocking pinch-zoom is an accessibility failure for low-vision users who need to enlarge text, and some mobile browsers ignore those values for that reason.
width=device-width matches layout width to the deviceinitial-scale=1 sets the initial zoom leveluser-scalable=noflex-grow, flex-shrink and flex-basis. What does flex: 1 actually mean?midThey control how flex items share space along the main axis (set by flex-direction):
auto means "use width/height, or the content size if that's also auto".2 gets twice as much of the extra space as an item with 1, not twice the total size.1; 0 prevents shrinking.The initial value is flex: 0 1 auto: don't grow, may shrink, size from content. flex: 1 expands to 1 1 0, so the basis is zero and all space is shared by the grow ratio, giving equal widths. flex: auto is 1 1 auto, sharing only the leftover space.
Gotcha: flex items default to min-width: auto, so they won't shrink below their content's minimum size; set min-width: 0 to let long text truncate.
.row { display: flex; gap: 1rem; }
/* fixed 240px, never grows or shrinks */
.sidebar { flex: 0 0 240px; }
/* takes the remaining space and can shrink below its content */
.content { flex: 1; min-width: 0; }flex-basis is the starting size on the main axisflex-grow shares positive free space proportionallyflex-shrink handles overflow, weighted by basisflex: 1 means 1 1 0: equal shares of the spacemin-width: autoLikely follow-up: Why does a flex item containing a long URL overflow instead of shrinking?
WAI-ARIA is a set of attributes that add or change semantics in the accessibility tree: roles (role="dialog", role="tab"), states (aria-expanded, aria-checked, aria-selected) and properties (aria-label, aria-labelledby, aria-describedby, aria-controls). It only changes what assistive technology is told; it adds no behaviour, focusability or keyboard support.
The first rule of ARIA: if a native HTML element or attribute already has the semantics and behaviour you need, use it. A <button> beats <div role="button">, which still needs tabindex="0" and Enter and Space handling.
The other rules: don't change native semantics unless you must; every interactive ARIA control must be keyboard operable; never put role="presentation" or aria-hidden="true" on focusable elements; and every interactive element needs an accessible name. Keep states like aria-expanded in sync with the UI. "No ARIA is better than bad ARIA": wrong roles actively mislead screen-reader users.
<button aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>
<li><a href="/docs">Docs</a></li>
<li><a href="/blog">Blog</a></li>
</ul>aria-hiddenLikely follow-up: What is the difference between aria-label and aria-labelledby?
alt text for images, and when should alt be empty?easyalt is the text alternative that screen readers announce and that browsers show if the image fails to load. Good alt text conveys the image's purpose in context rather than a literal description: the key takeaway of a chart, or, for a linked logo, where the link goes.
Guidelines:
alt="" so assistive technology skips it.alt, many screen readers fall back to reading the file name.alt plus a longer description in the surrounding text.CSS background images have no text alternative, so use them only for decoration.
<img src="q3-sales.png" alt="Sales rose 40% in Q3, driven by mobile">
<img src="divider.svg" alt="">
<a href="/"><img src="logo.svg" alt="Acme home page"></a>alt="", never a missing attribute<div>, <section> and <article>?easy<div> has no semantics. Use it purely as a styling or layout hook when no meaningful element fits.<section> is a thematic grouping of content, typically with its own heading, such as the parts of a long page. If you're only wrapping something for styling, it should be a <div> instead. A <section> is exposed as a region landmark only when it has an accessible name, for example via aria-labelledby pointing at its heading.<article> is self-contained content that would make sense on its own or could be syndicated: a blog post, a news story, a product card, a comment. Articles can be nested (comments inside a post) and can contain sections.A quick test: would it make sense on its own in a feed? Use <article>. Is it a titled part of something bigger? Use <section>. Neither? Use <div>. Related elements are <aside> for tangential content, <nav> for major navigation, and <header>/<footer> for introductory and closing content.
<div> has no semantic meaning; a styling hook only<section>: a thematic group, usually with a heading<article>: self-contained, independently distributable content<section> becomes a region landmarkCustom properties are properties whose names start with --, read with var(): color: var(--brand, blue), where the second argument is a fallback. They're live at runtime: they take part in the cascade and inherit down the DOM, so you can redefine them per component, per media query or per theme, and the page updates immediately. JavaScript can read them with getComputedStyle(el).getPropertyValue('--x') and set them with el.style.setProperty('--x', value).
Sass variables are compile-time: they're replaced by static values when the CSS is built, know nothing about the DOM, and can't react to a class or change inside a runtime media query.
Worth knowing: names are case-sensitive, and values aren't checked until they're substituted. If the substituted value is invalid for the property, the declaration becomes "invalid at computed-value time" and behaves like unset rather than falling back to an earlier declaration. @property registers a type, initial value and inheritance, which also makes custom properties animatable.
:root {
--space: 1rem;
--brand: #2563eb;
}
.card { padding: var(--space); border-color: var(--brand); }
.card--compact { --space: 0.5rem; }
@media (prefers-color-scheme: dark) {
:root { --brand: #60a5fa; }
}--name, used via var() with optional fallback@property adds a type, enabling animationLikely follow-up: What happens when a var() substitution produces an invalid value?
fr, minmax() and the difference between auto-fill and auto-fit in CSS Grid.mid1fr 2fr splits the remainder 1:2. 1fr behaves like minmax(auto, 1fr), so a track won't shrink below its content's minimum size; use minmax(0, 1fr) to allow that.minmax(200px, 1fr).repeat(auto-fill, minmax(200px, 1fr)) creates as many columns as fit. auto-fill keeps the empty tracks when there are fewer items than columns, so items keep their track width and the row has empty space at the end. auto-fit collapses empty tracks to zero, so the existing items stretch to fill the row. The difference only shows when there aren't enough items to fill a row.Together these give a responsive card grid without media queries. grid-template-areas complements them by naming regions with strings such as "header header" "sidebar main" and placing items with grid-area: header, which keeps layouts readable and easy to rearrange.
.cards {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
}fr divides the space left after fixed tracks and gapsminmax() sets a size range for a trackauto-fill keeps empty tracks; auto-fit collapses themrepeat(auto-fit, minmax()) is responsive without media queriesIn normal block flow, adjoining vertical (block-direction) margins combine into a single margin equal to the largest of them rather than their sum. With negative margins, the most negative one is added to the largest positive one. Horizontal margins never collapse.
It happens in three situations:
margin-bottom: 20px followed by margin-top: 30px gives a 30px gap, not 50px.It doesn't happen for flex and grid items, floats or absolutely positioned elements, or between an element that establishes a new block formatting context (for example display: flow-root or overflow: hidden) and its children. Using gap in flex and grid layouts, or applying margins in one direction only, avoids most surprises.
.a { margin-bottom: 20px; }
.b { margin-top: 30px; } /* gap between .a and .b is 30px, not 50px */
/* stops children's margins leaking out of the parent */
.parent { display: flow-root; }A block formatting context is an independent layout region in which block boxes are laid out one after another and floats are contained. What happens inside it doesn't affect layout outside, and vice versa.
An element that establishes a BFC:
Things that create one include the root element, floats, position: absolute or fixed, display: inline-block, table cells, overflow other than visible and clip, flex and grid items, contain: layout or paint, and multi-column containers. The purpose-built option is display: flow-root, which creates a BFC with no side effects, unlike overflow: hidden, which also clips content, shadows and outlines.
.media { display: flow-root; } /* contains the floated image */
.media img { float: left; margin-right: 1rem; }display: flow-root creates one without side effects<label>, or link them with for matching the input's id. That gives the control an accessible name for screen readers, and clicking the label focuses or toggles the input, a bigger hit area for checkboxes and radios. A placeholder isn't a label: it disappears as you type and often has poor contrast.email, tel, url, number, date, search, password, checkbox, radio. You get built-in validation and, on mobile, a suitable keyboard. autocomplete values such as email help users and password managers.<fieldset> and <legend>, especially radio groups.<button type="submit"> inside a <form>, so Enter submits and the form works without JavaScript.aria-describedby, and flag invalid fields with aria-invalid.<form>
<label for="email">Email</label>
<input id="email" type="email" name="email" autocomplete="email" required>
<fieldset>
<legend>Plan</legend>
<label><input type="radio" name="plan" value="free"> Free</label>
<label><input type="radio" name="plan" value="pro"> Pro</label>
</fieldset>
<button type="submit">Sign up</button>
</form><label>, via for/id or wrapping<fieldset> and <legend>aria-describedbyHTML has constraint validation built in, driven by attributes: required, minlength/maxlength, min/max/step for numbers and dates, pattern (a regular expression the whole value must match), and the input type itself (email, url). On submit, the browser blocks submission, focuses the first invalid field and shows a native message. novalidate on the form, or formnovalidate on a submit button, turns that off.
For styling there are :valid, :invalid, :required, :in-range/:out-of-range, and :user-invalid, which only matches after the user has interacted with the field, so fields aren't shown as errors on page load.
In JavaScript, the Constraint Validation API gives you input.validity (flags like valueMissing, typeMismatch, patternMismatch), checkValidity(), reportValidity() and setCustomValidity('message') for custom rules; an empty string clears the error.
Client-side validation is a UX feature only. Always validate on the server as well, because it's trivial to bypass.
<form>
<input name="zip" required pattern="[0-9]{5}" title="Five-digit ZIP code">
<input type="number" name="qty" min="1" max="10" step="1">
<button>Save</button>
</form>required, pattern, min/max, minlength, type:invalid and :user-invalidvalidity, setCustomValidity()tabindex="0" and tabindex="-1" do? Why avoid positive values?midKeyboard users move between interactive elements with Tab and Shift+Tab, activate them with Enter (links and buttons) or Space (buttons, checkboxes), and use arrow keys inside composite widgets such as radio groups, menus and tabs. Native links, buttons and form controls are focusable automatically, in DOM order.
tabindex="0" puts a non-interactive element into the tab order at its DOM position. Use it only for custom widgets that also get a role and keyboard handling.tabindex="-1" makes an element focusable programmatically with el.focus() but keeps it out of the Tab sequence. Useful for moving focus to a dialog or to a heading after a route change, and for roving tabindex inside composite widgets.Also keep a visible focus indicator (style :focus-visible rather than removing outlines), keep DOM order consistent with visual order, and provide a "skip to main content" link.
tabindex="0" joins the tab order; -1 is programmatic onlytabindex breaks the natural order; avoid it:focus-visible:hover or when a class is toggled. You declare which property, how long, and the easing: transition: transform 200ms ease-out. They only have a start and an end state, need something to trigger the change, and run once per change, reversing smoothly if the change is undone midway.@keyframes to define any number of intermediate steps and can run on their own, without a state change. They support animation-iteration-count (including infinite), animation-direction (such as alternate), animation-delay, animation-fill-mode (for example to keep the final state) and animation-play-state (to pause).Rule of thumb: transitions for simple interactive state changes, animations for loaders, attention effects, and multi-step or looping motion. In both cases prefer animating transform and opacity, and respect prefers-reduced-motion.
.btn { transition: transform 150ms ease-out; }
.btn:hover { transform: translateY(-2px); }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
}@keyframes and run without a triggertransform/opacity; respect prefers-reduced-motiontransform and opacity smoother than animating top, left or width? What does will-change do?hardEvery frame goes through style, layout, paint and composite. Animating width, height, top or left changes geometry, so each frame re-runs layout (possibly for other elements too) and paint on the main thread. That easily blows the frame budget (about 16ms at 60fps) and causes jank.
transform and opacity don't affect layout. When the element is on its own compositor layer, the compositor thread can apply them to already-painted content, often on the GPU, skipping layout and paint entirely. The animation can then stay smooth even while the main thread is busy running JavaScript.
will-change: transform hints that a property is about to change, so the browser can promote the element to its own layer ahead of time and avoid a hiccup when the animation starts. Use it sparingly: every layer costs memory, and blanket will-change on many elements can make performance worse. Add it shortly before an animation and remove it afterwards, or keep it only on elements that animate constantly. Like the real property, it also creates a stacking context.
transform and opacity can run on the compositorwill-change promotes a layer early; use it sparinglyLikely follow-up: How would you animate an element from height: 0 to its natural height smoothly?
srcset, sizes and <picture> work for responsive images, and what does loading="lazy" do?hardw descriptors lists the same image at several widths (hero-800.jpg 800w, hero-1600.jpg 1600w). sizes tells the browser how wide the image will be displayed at each viewport size ((min-width: 60em) 50vw, 100vw), because it must choose before layout is known. The browser combines that with the device pixel ratio to pick a candidate. Without sizes, it assumes 100vw.logo.png 1x, logo-2x.png 2x) suit fixed-size images.<picture> with <source> children handles art direction (different crops per breakpoint via media) and format fallbacks (type="image/avif", then WebP, then the <img>). The first matching <source> wins; the inner <img> is required and carries alt.fetchpriority="high" for it instead.Always set width and height attributes so the browser can reserve space and avoid layout shift.
<picture>
<source type="image/avif"
srcset="hero-800.avif 800w, hero-1600.avif 1600w"
sizes="(min-width: 60em) 50vw, 100vw">
<img src="hero-800.jpg"
srcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(min-width: 60em) 50vw, 100vw"
width="1600" height="900" alt="Team planning session at a whiteboard">
</picture>srcset with w plus sizes lets the browser choose resolution<picture> for art direction and format fallbacks<img> is required and holds altloading="lazy" defers off-screen images; not the LCP imagewidth and height to prevent layout shiftinherit, initial, unset and revert do?midInheritance passes a parent's computed value to a child that has no declared value of its own. Mostly text-related properties inherit: color, font-*, line-height, letter-spacing, text-align, visibility, cursor, list-style. Most box and layout properties don't: margin, padding, border, width, background, display, position. Custom properties inherit by default.
Form controls often don't pick up the page font because the browser's default styles set a font on them directly, which is why resets include font: inherit for button, input, select and textarea.
The CSS-wide keywords:
inherit: use the parent's computed value, even for a non-inherited property.initial: the property's initial value from the spec, not the browser default (display: initial is inline, even on a div).unset: acts as inherit for inherited properties and initial for the rest.revert: roll back to the previous cascade origin, usually the browser's default styles; revert-layer rolls back to the previous cascade layer.all: unset or all: revert apply a keyword to every property at once.
initial is the spec initial value, not the browser defaultunset acts as inherit or initial; revert restores browser styles!important do, and why is it usually considered a code smell?easy!important moves a declaration into a higher-priority tier of the cascade. It beats every normal declaration regardless of specificity or source order, including inline styles. When two important declarations compete, the usual rules (layers, specificity, order) decide between them.
Importance also reverses some precedence: an important user-agent or user style beats an important author style, and important declarations in earlier cascade layers beat those in later layers.
The problem is escalation. In practice the only way to override an !important is another !important with equal or higher priority, which leads to specificity wars and brittle CSS. It usually signals a deeper issue, such as overly specific selectors or fighting a third-party stylesheet.
Legitimate uses exist: utility classes that must always win (.hidden { display: none !important; }), user stylesheets for accessibility, or overriding inline styles you don't control. Better long-term tools are flat specificity (single classes, :where()) and cascade layers.
!important@layer@layer), and what problem do they solve?hardCascade layers let you group styles into named layers whose order you declare explicitly. In the cascade, layer order is compared before specificity, so a rule in a later layer beats a rule in an earlier layer even when the earlier rule has a far more specific selector.
That solves specificity battles between different sources of CSS: resets, third-party libraries, design-system components and utilities. You declare the order once, e.g. @layer reset, vendor, components, utilities;, and utilities beat components without !important or extra selector weight.
Key rules:
@layer components { } blocks add to that layer.!important declarations the order reverses: earlier layers win, and unlayered important styles are the weakest.@import url(vendor.css) layer(vendor);.components.buttons.@layer reset, vendor, components, utilities;
@layer components {
.card .title { color: navy; }
}
@layer utilities {
.text-red { color: red; } /* wins despite lower specificity */
}!important, the layer order reverses!important:is(), :where() and :has() do, and how does each affect specificity?hard:is() matches any element that matches one of the selectors in its list, which shortens repetitive selectors: :is(h1, h2, h3) a instead of three rules. Its specificity is that of its most specific argument, so :is(#id, p) counts as an ID even when it matches a p.:where() matches exactly the same way but always has zero specificity, which makes it ideal for defaults and reset or library styles that should be trivially easy to override.:has() is a relational pseudo-class, often called the "parent selector": it matches an element if a relative selector matches from it, e.g. .card:has(img) or label:has(+ input:invalid). It can look at descendants, children and following siblings. Its specificity is also that of its most specific argument, and unlike the other two its list is not forgiving./* (0,0,0): trivially easy to override */
:where(ul, ol) { margin: 0; padding: 0; }
:is(article, aside) :is(h2, h3) { margin-top: 0; }
.card:has(img) { grid-template-rows: auto 1fr; }
form:has(:user-invalid) .submit { opacity: 0.5; }:is() groups selectors; takes its most specific argument:where() matches the same with zero specificity:is() and :where() use forgiving selector lists:has() selects based on descendants or following siblingsdata-* attributes, and how do you use them from CSS and JavaScript?easydata-* attributes let you store custom data on any HTML element without inventing non-standard attributes, e.g. <li data-id="42" data-status="done">. The part after data- must not contain uppercase letters.
dataset property, which converts kebab-case to camelCase, so data-user-id becomes el.dataset.userId. Values are always strings. getAttribute and setAttribute work too.[data-status="done"], and show values in generated content with content: attr(data-label).They're useful for state hooks, test selectors, and passing configuration from server-rendered HTML to scripts. Don't use them for content that should be visible or semantic (use proper elements), and don't store anything sensitive, since anyone can read the markup. Changing a data-* attribute conveys nothing to assistive technology; use ARIA states such as aria-expanded for that.
data- prefixel.dataset, kebab-case becomes camelCaseattr() in content<link rel="preload">, prefetch, preconnect and dns-prefetch?midThey're resource hints that tell the browser about work it would otherwise discover too late:
as (as="font", as="image") for the right priority and cache matching, and fonts also need crossorigin. Preloads that go unused waste bandwidth, and browsers warn about them in the console.Overusing hints makes them compete with the resources that really matter.
preload: high-priority fetch for the current page; needs asprefetch: low-priority fetch for a likely next navigationpreconnect: warms up DNS, TCP and TLS earlydns-prefetch: DNS lookup onlyBEM (Block, Element, Modifier) is a class-naming convention:
.card..card__title, .card__image..card--featured, .card__title--large.Elements stay flat under their block (.card__body__title is discouraged), even if the markup is nested.
Benefits: every selector is a single class, so specificity stays flat and predictable; names show where a style belongs; styles don't depend on DOM nesting, so markup can change without breaking CSS; and there are fewer accidental collisions in a global stylesheet.
Downsides: long class names and verbose markup, and it's a discipline rather than enforced scoping. That's why many teams now get scoping from CSS Modules, Shadow DOM or framework-scoped styles, or use utility classes, although BEM-style naming is still common in design systems.
__), Modifier (--)calc(), min(), max() and clamp() work? Give a practical use of clamp().midThey're CSS math functions evaluated by the browser, so they can mix units that a preprocessor couldn't combine at build time.
calc() does arithmetic: width: calc(100% - 2rem). Spaces are required around + and - (calc(100%-2rem) is invalid) but not around * and /.min() picks the smallest value: width: min(100%, 60rem) is a fluid container capped at 60rem.max() picks the largest: padding: max(1rem, 3vw).clamp(MIN, PREFERRED, MAX) is equivalent to max(MIN, min(PREFERRED, MAX)): the preferred value is used while it stays within the bounds.The classic use is fluid typography: font-size: clamp(1rem, 0.8rem + 1vw, 1.5rem) scales smoothly with the viewport but never drops below 1rem or exceeds 1.5rem. Including a rem term keeps it responsive to the user's zoom and font-size settings, unlike pure vw. The same works for fluid spacing and widths, and custom properties can be used inside: calc(var(--space) * 2).
.container { width: min(100% - 2rem, 70rem); margin-inline: auto; }
h1 { font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem); }
.stack > * + * { margin-top: calc(var(--space, 1rem) * 1.5); }calc() needs spaces around + and -clamp(min, preferred, max) bounds a fluid valuerem and vw inside clamp()Media queries respond to the viewport (and device features). Container queries respond to the size of an ancestor container, so a component adapts to the space it's actually given. The same card can stack vertically in a narrow sidebar and sit horizontally in a wide main column, without knowing anything about the page layout.
You opt an element in with container-type: inline-size, which allows queries on its inline size (width in horizontal writing). container-type: size allows querying both axes, but the container then no longer sizes itself from its content in either axis, so it needs an explicit height. You can name it with container-name, then @container (min-width: 30rem) { ... } styles its descendants. An element can't restyle itself based on its own container query.
There are also container query units such as cqi, cqw and cqh, which are relative to the container's size, and style queries such as @container style(--variant: compact), which test custom property values.
.card-list { container-type: inline-size; container-name: cards; }
.card { display: grid; gap: 1rem; }
@container cards (min-width: 30rem) {
.card { grid-template-columns: 10rem 1fr; }
}container-type: inline-sizecqi are relative to the containerfloat: left or float: right takes an element out of normal flow and shifts it to one side of its container. Inline content such as text wraps around it, while following block boxes behave as if the float weren't there (only their line boxes get shorter).
Two classic problems:
clear: both on an element pushes it below any preceding floats.The clearfix hack adds an ::after pseudo-element with content: "", display: table and clear: both to the parent, forcing it to contain its floats. Today the cleaner fix is display: flow-root, which makes the parent a block formatting context.
Floats were used for whole page layouts before Flexbox and Grid; that's legacy now. Their remaining real use is their original purpose: wrapping text around an image or pull quote, optionally shaped with shape-outside.
display: flow-root makes the parent contain floatsSingle-line truncation needs three properties together:
white-space: nowrap keeps the text on one line,overflow: hidden clips whatever overflows,text-overflow: ellipsis draws "…" at the clip point.The element also needs a constrained width: a block with a set or available width, or a flex item with min-width: 0, because flex items won't shrink below their content by default.
For multi-line clamping, the widely used form combines display: -webkit-box, -webkit-box-orient: vertical, -webkit-line-clamp: 3 and overflow: hidden. Despite the prefix, this works in all modern browsers; a standard line-clamp property is specified as its successor.
Think about access to the hidden text: screen readers still read the full content because it's in the DOM, but sighted users may need a way to see it, such as an expand control or a tooltip.
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}nowrap, overflow: hidden, text-overflow: ellipsismin-width: 0-webkit-line-clamp with display: -webkit-boxModal dialogs, following the WAI-ARIA Authoring Practices dialog pattern:
role="dialog" with aria-modal="true" and an accessible name via aria-labelledby.The native <dialog> element opened with showModal() does most of this for you: it renders in the top layer, makes the rest of the page inert, closes on Escape, and restores focus to the previously focused element when it closes.
SPA route changes don't reload the page, so focus stays on the clicked link and nothing is announced. Move focus to the new view's main heading or container (with tabindex="-1"), update document.title, and optionally announce the change through a live region.
inert<dialog> with showModal() handles most of itLikely follow-up: What happens to focus when the focused element is removed from the DOM?
Landmarks are the major regions of a page, which screen-reader users can list and jump between. Native elements map to landmark roles:
<header> is banner and <footer> is contentinfo, but only when they aren't nested inside <article>, <aside>, <main>, <nav> or <section><nav> is navigation<main> is main (one visible per page)<aside> is complementary at the top level or inside <main> (or anywhere once it has an accessible name)<form> and <section> become form and region landmarks only when they have an accessible nameWhen there are several landmarks of the same type, such as two <nav> elements, label them with aria-label ("Primary", "Breadcrumb") so users can tell them apart.
Headings are one of the most common ways screen-reader users navigate: they pull up a list of headings or jump from one to the next. Give the page one clear <h1> describing its topic, avoid skipping levels, and choose each level by the document structure rather than by visual size, which belongs in CSS.
<header>, <nav>, <main>, <aside>, <footer>aria-labelWCAG level AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text, which is at least 18pt (about 24px) regular or 14pt (about 18.66px) bold. Level AAA raises these to 7:1 and 4.5:1. Non-text elements that users need to perceive, such as input borders, focus indicators and meaningful icons, need at least 3:1 against adjacent colors.
Don't rely on color alone to convey information: an error shown only as a red border, or a chart that distinguishes series only by hue, fails for color-blind users. Add text, icons, patterns or underlines; links within body text usually need a non-color cue such as an underline.
Check contrast with the color picker in browser DevTools or tools such as axe and Lighthouse. Test hover, focus and disabled states and dark mode too, and respect user preferences like prefers-contrast and forced colors mode.
<title> and <meta name="description">: the title is the main clickable text in search results, and the description often becomes the snippet.<h1>, logical headings, and real <a href> links with descriptive text (not "click here"), so crawlers can discover and understand your pages.alt text to meaningful images.<link rel="canonical"> to point duplicate URLs at the preferred one, and hreflang alternates for translated pages.robots meta tag and robots.txt, and provide an XML sitemap.Open Graph tags improve social link previews but aren't a ranking factor, and the keywords meta tag is ignored by Google.
<title> and meta description on every pageWeb Components are a set of native browser APIs for building reusable custom elements:
customElements.define('user-card', UserCard) registers a new tag whose class extends HTMLElement and gets lifecycle callbacks such as connectedCallback, disconnectedCallback and attributeChangedCallback. Names must contain a hyphen.attachShadow({ mode: 'open' }) attaches an encapsulated DOM tree. Styles inside don't leak out and page selectors don't reach in, although inherited properties like color and font and custom properties do cross the boundary.<template> holds inert markup, and <slot> projects the host's light-DOM children into the shadow tree.Styling hooks are :host for the element itself, ::slotted() for projected children, ::part() for parts the component exposes, and custom properties as a theming API. Declarative Shadow DOM (<template shadowrootmode="open">) enables server rendering.
The trade-off: framework-agnostic and long-lived, but you manage rendering and state yourself.
<user-card><span slot="name">Ada</span></user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML =
'<style>p { color: teal; }</style><p>Hi, <slot name="name"></slot></p>';
}
}
customElements.define('user-card', UserCard);
</script>:host, ::slotted() and ::part()<iframe>, and how does the sandbox attribute help?hardThere are two directions to think about.
Embedding untrusted content such as user HTML, ads or third-party widgets: an empty sandbox attribute applies every restriction. Scripts, form submission, popups and top-level navigation are blocked, and the content is treated as a unique opaque origin, so it can't access its real origin's cookies or storage. You then re-enable only what's needed with tokens like allow-scripts, allow-forms, allow-popups and allow-same-origin. Never combine allow-scripts with allow-same-origin for content from your own origin: the framed script could simply remove the sandbox. The allow attribute (Permissions Policy) controls features such as camera and geolocation.
Your own site being framed (clickjacking): an attacker overlays your page invisibly to trick users into clicking. Prevent it with the Content-Security-Policy: frame-ancestors header, or the older X-Frame-Options.
Cross-origin frames communicate with the parent only through postMessage; always check event.origin on received messages. Also give every iframe a title for accessibility.
sandbox applies every restriction; opt in with tokensallow-scripts with allow-same-origin for same-origin contentframe-ancestorspostMessage and verify event.originAll three address the same problems of plain global CSS: no scoping, name collisions, and styles nobody dares delete.
Choose based on the team and constraints: server rendering and tight performance budgets favour build-time approaches, while theming works with any of them, especially combined with custom properties. Consistency within one codebase matters more than the choice itself.
A preprocessor compiles an extended syntax into plain CSS at build time. Sass (usually its SCSS syntax), Less and Stylus added features CSS lacked:
$brand),Modern CSS has absorbed much of this: custom properties (more powerful than preprocessor variables because they're live and cascade), native nesting, calc() and clamp(), color-mix(), @layer for organization, and container queries. PostCSS-based tools handle vendor prefixing and bundling.
So Sass is no longer essential, but it's still common in existing codebases and design systems for mixins, loops that generate utility classes, and other compile-time logic. A typical modern setup is plain CSS with PostCSS or a bundler, adding Sass only when you need its build-time features. With either, avoid deep nesting: it inflates specificity and couples styles to DOM structure.
Build it on custom properties: define semantic tokens (--bg, --text, --surface) on :root, use only those tokens in components, then swap their values for dark mode.
@media (prefers-color-scheme: dark).data-theme="dark" on the <html> element and saving the choice in localStorage. Apply it with a small inline script in the <head>, before first paint, to avoid a flash of the wrong theme. The explicit choice should override the media query.color-scheme (to light dark, or to a single value once the user has picked a theme) so the browser renders form controls, scrollbars and the default page background to match. The light-dark() function can pick between two colors based on the active scheme.Don't just invert colors: avoid harsh pure white on pure black, adjust shadows, images and brand colors, and recheck contrast ratios in both themes.
:root { color-scheme: light dark; --bg: #fff; --text: #111; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; --bg: #121212; --text: #e8e8e8; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #121212; --text: #e8e8e8; }
}
body { background: var(--bg); color: var(--text); }prefers-color-scheme follows the OS setting<html>, saved in localStoragecolor-scheme themes native controlsLoading is usually the biggest factor, because CSS is render-blocking:
@import inside stylesheets, which chains requests.font-display value such as swap or optional so text isn't invisible while fonts load.Rendering:
transform and opacity rather than layout properties, and use will-change sparingly.width/height attributes, aspect-ratio) to prevent layout shift.content-visibility: auto skips rendering work for off-screen sections, and contain limits how far layout and paint changes spread.Selector matching is rarely the bottleneck: browsers match right to left and are highly optimized. DOM size and how often styles are invalidated matter more.
@import chains; preload fonts and set font-displaycontent-visibility and contain limit rendering workmargin-inline-start instead of margin-left?midLogical properties describe directions relative to the writing mode and text direction rather than the physical screen:
So margin-inline-start is the left margin on a left-to-right page and automatically becomes the right margin under dir="rtl". There are equivalents for most box properties: padding-block, border-inline-end, inset-inline-start, inline-size and block-size for width and height, text-align: start, and shorthands like margin-inline: auto.
Using them makes right-to-left support mostly automatic instead of requiring mirrored overrides, and they work naturally with vertical writing modes. Flexbox and Grid alignment are already direction-aware: justify-content: start follows the text direction.
margin-inline-start flips automatically for RTLaspect-ratio property do, and how was the same effect achieved before it existed?midaspect-ratio: 16 / 9 gives a box a preferred width-to-height ratio: when one dimension is set or determined by layout (like a block filling its container's width), the other is calculated from the ratio. It's ideal for video embeds, cards, thumbnails and loading skeletons that should keep their shape.
Details worth knowing:
width and height are set, the ratio is ignored.min-height: 0 or an overflow other than visible to enforce the ratio.aspect-ratio: auto 16 / 9 uses an element's natural ratio when it has one (such as an image) and the given ratio otherwise.width and height attributes on <img> to derive a ratio before the image loads, which reserves space and prevents layout shift.object-fit: cover on images to crop without distortion.Before it existed, the padding hack was used: percentage padding resolves against the containing block's width, so a wrapper with padding-top: 56.25% (9 ÷ 16) and an absolutely positioned child filling it made a 16:9 box.
width and height are setpadding-top hackwidth/height attributes reserve space via the ratioCharacter references, often called entities, represent characters that are reserved in HTML or hard to type. They can be named (&, <, >, ", , ©) or numeric (© or ©).
What to escape:
& as & and < as < in text, so the parser doesn't read them as the start of a character reference or a tag (> is usually escaped too, for symmetry);" or '.With UTF-8 (<meta charset="utf-8">) you can type most other characters directly; is still handy to prevent a line break between two words.
Escaping is essential for security: inserting user input into HTML without escaping enables cross-site scripting (XSS). Frameworks such as React and Angular escape interpolated text by default; the danger lies in APIs that bypass that, like innerHTML or dangerouslySetInnerHTML. Use textContent for text, and sanitize (for example with DOMPurify) when you genuinely need to render HTML. Escaping also depends on context: HTML text, attributes, URLs and JavaScript each need different encoding.
& and < in text, and quotes in attributestextContent; sanitize when rendering HTMLUse @media print { ... }, or a separate stylesheet linked with <link rel="stylesheet" media="print">, to adapt the page for paper:
print-color-adjust: exact (often still written with the -webkit- prefix) asks them to keep them.a[href^="http"]::after { content: " (" attr(href) ")"; }.break-before, break-after and break-inside: avoid (older code uses page-break-*), so headings aren't stranded at the bottom of a page and figures or table rows aren't split.@page rule, and use physical units like pt or cm where precise sizing matters.Test with print preview or the DevTools option to emulate print media. Printing is also how many users save pages as PDFs, so it's worth the effort.
@media print {
nav, .ads, button, video { display: none; }
body { color: #000; background: #fff; font-size: 12pt; }
a[href^="http"]::after { content: " (" attr(href) ")"; }
h2, h3 { break-after: avoid; }
figure, tr { break-inside: avoid; }
}
@page { margin: 2cm; }@media print or a print stylesheetattr(href) in generated contentbreak-inside and break-after@page sets page marginsNo questions match that filter.