Full-Stack Questions

HTML & CSS Interview Questions

Semantic HTML, the cascade, layout with flexbox and grid, container queries, stacking contexts, and common CSS traps.

Lesson 0132 min read#html#css

On this page

  1. Words used in this lesson
  2. Semantic HTML and accessibility basics
  3. Forms and validation
  4. Loading and performance attributes
  5. The cascade: which rule wins
  6. The box model and margin collapse
  7. Layout: flexbox, grid, subgrid and container queries
  8. Positioning and stacking contexts
  9. Responsive design
  10. Modern CSS you can use now
  11. Animations and rendering performance
  12. CSS architecture
  13. Dark mode and print
  14. Typical debugging traps
  15. Interview questions
  16. Key takeaways
  17. What's next

In one line: HTML says what things are (a button, a heading, a form field) and CSS says how they look and where they sit. Strong answers at 4+ years come from three habits. Use the right native element first. Know the rules the browser follows (the cascade, the box model, layout, stacking). Pick the simplest tool (a defer attribute, a min-width: 0, a flow-root) before JavaScript or a hack.

Most interview mistakes here are not about syntax. They are about not knowing why a rule does not apply, why a z-index does nothing, or why a page jumps while it loads. This lesson explains the rules behind those surprises, then the questions that test them. I ran every behaviour claim in a real browser.

Words used in this lesson

  • Semantic HTML is HTML where each tag's name describes its meaning (<nav>, <button>, <h1>), not just its look.
  • A screen reader is a program that reads the page aloud for people who cannot see it. It reads the accessibility tree, a simplified copy of the page that lists each element's role (button, link, heading), name and state.
  • The cascade is the set of rules that picks one value when many CSS rules set the same property on one element. Specificity is a score that says how strongly a selector (the part before {) claims an element.
  • LCP (Largest Contentful Paint) is the time until the biggest visible element is drawn. CLS (Cumulative Layout Shift) is a score for how much content jumps while loading. See Browser, Lesson 09.

Semantic HTML and accessibility basics

Definition: an element is semantic when its tag tells the browser what role it plays. The browser then gives it behaviour for free: focus, keyboard support and a role for screen readers. Everyday comparison: a road sign has a shape and colour that tell you its meaning before you read it. A <div> is a blank board.

HTML
<header>…logo and <nav aria-label="Main">…</nav></header><main>  <h1>Orders</h1>  <section aria-labelledby="open-h"><h2 id="open-h">Open orders</h2>…</section></main><footer>…</footer>
  • Landmarks (header, nav, main, aside, footer) let a screen-reader user jump to the main content. Use one <main> per page. Give each of two <nav> elements an aria-label.
  • Headings form an outline. Choose the level for the meaning, then use CSS for the size.
  • A <button> does something on this page. An <a href> goes somewhere. A <div onClick> is neither: Tab skips it, Enter and Space do nothing, and it has no role.
  • Images need alt text that says what the image means. Use alt="" for decoration.
  • Focus. Everything clickable must be reachable with Tab and visibly focused. Never write outline: none without a replacement. :focus-visible shows the ring for keyboard users and usually not for mouse clicks.
  • Numbers to know. WCAG (Web Content Accessibility Guidelines) level AA asks for text contrast of 4.5:1 (3:1 for large text) and, since version 2.2, touch targets of at least 24 by 24 CSS pixels.
  • ARIA (Accessible Rich Internet Applications) is a set of attributes (role, aria-label, aria-expanded) that add meaning when HTML cannot. The first rule: do not use ARIA if a native element does the job. ARIA changes what is announced but adds no behaviour. A role="button" on a div still ignores the keyboard.

Native elements that replace old JavaScript widgets: <dialog> with showModal() (a modal with focus handling), the popover attribute (a floating panel that closes on outside click), <details> with <summary> (give several the same name and only one stays open), and inert (makes a whole branch unfocusable and unclickable).

display: none and hidden remove an element from the accessibility tree too. To hide something only visually, use a "visually hidden" class: position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;. Tailwind calls it sr-only. For React-side accessibility see Frontend Depth, Lesson 06.

Forms and validation

Definition: a form is a group of fields the browser submits as one request. Validation is checking that the values are acceptable. The browser has built-in (constraint) validation: you describe rules with attributes, and it checks them on submit.

HTML
<form action="/signup" method="post">  <label for="email">Email</label>  <input id="email" name="email" type="email" required autocomplete="email"         aria-describedby="email-help">  <p id="email-help">We never share it.</p>  <label>Age <input name="age" type="number" min="18" max="120" inputmode="numeric"></label>  <label>Code <input name="code" pattern="[A-Z]{3}[0-9]{3}" title="Three capitals, then three digits"></label>  <button>Create account</button>   <!-- default type is "submit" --></form>
  • Always connect a label to its input (for/id, or wrap the input). Clicking the label focuses the input and the screen reader announces the name. A placeholder is not a label: it vanishes when the user types.
  • The right type (email, tel, number) gives the right mobile keyboard and a first check. autocomplete lets password managers fill the field.
  • A <button> in a form is type="submit" by default. A "Cancel" button needs type="button". This is a common bug.
  • The Constraint Validation API is the JavaScript side. input.validity has flags such as valueMissing, typeMismatch, patternMismatch, rangeOverflow. form.checkValidity() returns true or false. input.setCustomValidity("text") marks a field invalid with your message, and setCustomValidity("") clears it. While a message is set, the form cannot submit.
  • Styling: :invalid matches as soon as the page loads, so an empty required field is red before the user typed. :user-invalid matches only after the user interacted. Use :user-invalid for error styles (Chrome 119, Firefox 88, Safari 16.5).
  • Add novalidate to the form when you run your own validation and messages.
  • Accessible errors: write the message as text beside the field, link it with aria-describedby, set aria-invalid="true", and do not use red alone.

Client-side validation is for comfort, not security. Anyone can send a request without your form. Validate again on the server (Backend, Lesson 03).

Loading and performance attributes

Small attributes change when the browser fetches and runs things. See Browser, Lesson 00 for the full path.

AttributeWhat it doesUse it for
<script src> (plain)Blocks HTML parsing while it downloads and runs.Almost never.
deferDownloads in parallel, runs after parsing, in order, before DOMContentLoaded.Your app code.
asyncDownloads in parallel, runs as soon as it arrives, in no fixed order.Independent scripts, such as analytics.
type="module"Acts like defer by default.Modern bundles.
rel="preload"Fetches a file you know you need soon, at high priority. Needs as.The LCP image, a key font.
rel="preconnect"Opens the connection (DNS, TCP, TLS) to another origin early.A CDN or API you call at once.
rel="prefetch"Fetches for a later page, at low priority.The next likely page.
loading="lazy"Delays an <img> or <iframe> until near the viewport.Images below the fold.
fetchpriority="high"A hint to fetch this resource earlier.The one LCP image.

What I checked in a real browser:

  • Three defer scripts ran in written order after the DOM existed. Three async scripts ran in the order they finished.
  • A plain script blocks parsing, not rendering by itself. Old advice says "put scripts before </body>". Today the better answer is defer in the <head>: the browser finds the file early and still runs it after parsing.
  • A font preload needs crossorigin, even on your own origin. Without it the preloaded file is not reused and the font downloads twice.

Never put loading="lazy" on the LCP image. The hero is eager with fetchpriority="high". Lazy is for images below the fold.

Give images width and height (or CSS aspect-ratio), so the browser reserves the space and the page does not jump (a lower CLS). Use srcset and sizes so a phone does not download a desktop image.

HTML
<img src="/hero-800.avif" width="1200" height="600" alt="Warehouse robots at work"     srcset="/hero-800.avif 800w, /hero-1600.avif 1600w"     sizes="(min-width: 900px) 900px, 100vw"     fetchpriority="high">

In Next.js, <Image> lazy-loads by default and takes sizes and fetchPriority="high" (or preload) for the LCP image. The older priority prop is deprecated since Next.js 16.

The cascade: which rule wins

Definition: the cascade is the algorithm that picks one winning value. It asks, in order:

  1. Relevance: does the selector match, and is the media condition true?
  2. Origin and importance: browser styles, user styles, your styles; !important flips the order. Running transitions beat even !important.
  3. Cascade layers (@layer): a later layer beats an earlier one, and unlayered styles beat all layers.
  4. Specificity: the score of the selector.
  5. Order of appearance: the last rule wins.

Specificity is only the fourth question.

Specificity

Count three columns: IDs - classes - types. Compare from left to right. A bigger left column always wins.

SelectorScore
p0-0-1
.card, [type="text"], :hover0-1-0
#nav1-0-0
nav ul li a.active0-1-4
:where(#a) p0-0-1 (:where() is always zero)
:is(#a, .c) p1-0-1 (:is() takes its strongest argument)
p:not(.a)0-1-1
inline style="…"beats any selector

* and combinators (>, +, space) add nothing. Eleven classes do not beat one ID: the columns do not carry over.

Cascade layers

Definition: @layer creates a named layer. You set the order of layers once, and that order beats specificity.

CSS
@layer reset, base, components, utilities;   /* order = priority; last wins */@layer components { .btn { padding: .5rem 1rem; } }@layer utilities  { .p-0 { padding: 0; } }   /* wins, even with lower specificity */

You stop fighting with #app .page .card .btn: a utility wins because its layer is later. Tailwind v4 is built on this: its CSS starts with @layer theme, base, components, utilities;. Two traps:

  • Unlayered CSS beats every layer. A plain p { color: red } wins over @layer utilities { #id.x.y { color: blue } }. When you add layers to an old project, put the old CSS in a layer too.
  • !important reverses the layer order. An important rule in an earlier layer beats one in a later layer.

Inheritance

Definition: inheritance means a child takes its parent's computed value when no rule sets that property. Text properties inherit (color, font-*, line-height, text-align, visibility, cursor). Box properties do not (margin, padding, border, background, width).

Custom properties

Definition: a custom property (CSS variable) is a value you name with -- and read with var(). Unlike a Sass variable, it is live in the browser: it follows the cascade and inheritance, and JavaScript or a media query can change it.

CSS
:root { --gap: 1rem; --brand: oklch(60% 0.2 260); }.card { padding: var(--gap); border-color: var(--brand); margin: var(--missing, 7px); }.card.compact { --gap: .5rem; }   /* the children read the new value too */
  • var(--x, fallback) uses the fallback only when --x is not defined.
  • Trap: if --x is defined but not valid for the property (color: var(--x) with --x: 12px), the property becomes invalid at computed-value time. It falls back to the inherited or initial value, not to the earlier line you wrote. In my test, color: red; color: var(--c) gave black.
  • @property --n { syntax: "<length>"; inherits: false; initial-value: 0px; } registers a typed variable. A bad value then falls back to initial-value, and the variable can be animated (Chrome 85, Firefox 128, Safari 16.4).

The box model and margin collapse

Definition: every element is a box of four layers, from inside out: content, padding, border, margin. box-sizing decides what width means. With width: 200px, padding: 20px and border: 5px:

box-sizingRendered width
content-box (default)200 + 40 + 10 = 250px
border-box200px (padding and border sit inside)

Almost every project starts with *, *::before, *::after { box-sizing: border-box; } (Tailwind's base layer does). Also: percentage padding and margin use the containing block's width, even top and bottom, and height: 50% does nothing if the parent has no definite height. Prefer rem over px for sizes, so the page respects the user's font setting.

Margin collapse

Definition: margin collapse is when touching vertical margins of block boxes merge into one margin, the biggest of them. It happens in three cases: two siblings; a parent and its first or last child when nothing separates them (no border, padding or inline content); and an empty block's own top and bottom margins.

What I measured: siblings with 20px margins had a 20px gap. A child's margin-top: 30px pushed the parent down 30px. display: flow-root on the parent made that 0. In a flex or grid container the gap was 40px, since flex and grid items never collapse. A +30px and a -10px margin gave 20px.

Fixes: display: flow-root on the parent (a box that contains its children's margins and floats), a border or padding, or gap in flex or grid.

flow-root is the modern answer to the old "clearfix" and "BFC" (block formatting context) questions. Explain the idea, then say flow-root is the one-line fix.

Layout: flexbox, grid, subgrid and container queries

Flexbox

Definition: flexbox (display: flex) is a one-direction layout that places items in a row or column and shares the free space.

CSS
.toolbar { display: flex; gap: .5rem; align-items: center; justify-content: space-between; }.toolbar .grow { flex: 1; }
  • flex: 1 is 1 1 0%; flex: auto is 1 1 auto; flex: none is 0 0 auto. The numbers are: grow, shrink, starting size.
  • The classic trap: a flex item has min-width: auto, so it will not shrink below its content. A long nowrap line pushed an item in a 200px container to 568px. min-width: 0 (plus overflow: hidden or overflow-wrap: anywhere) brought it back to 200px. In a column, use min-height: 0.

Grid

Definition: grid (display: grid) is a two-direction layout with rows and columns.

CSS
.cards {  display: grid;  gap: 1rem;  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));}

This is the responsive grid with no media query: each column is at least 15rem (or full width on a tiny screen) and shares the extra space. auto-fit vs auto-fill: both create as many columns as fit. With two items in 700px, auto-fill kept the empty tracks (175px each), while auto-fit collapsed them so the items stretched (350px each).

Difference in one line: flexbox lets the content decide sizes along one line; grid lets the layout decide sizes in two directions. Grid for page layouts and card grids, flex for toolbars and rows of buttons. Mixing them is normal.

Subgrid

Definition: grid-template-columns: subgrid lets a nested grid reuse the tracks of its parent grid. Use it when the title, text and button of separate cards must line up across the cards. Support: Chrome 117, Firefox 71, Safari 16.

Container queries

Definition: a container query (@container) applies CSS based on the size of a parent container, not the screen. A media query asks "how wide is the window?" A container query asks "how wide is the box I live in?" A card then works in a narrow sidebar and in a wide area.

CSS
.card-wrap { container-type: inline-size; container-name: card; }.card { display: grid; gap: .5rem; }@container card (min-width: 30rem) {  .card { grid-template-columns: 8rem 1fr; }   /* side by side when its box is wide */}
  • Mark the parent with container-type: inline-size. The query can style only descendants of the container, never the container itself (I tested it).
  • container-type: size also watches height, so the box no longer takes its height from its content; mine collapsed to 0. Prefer inline-size.
  • Units such as cqi (1% of the container's inline size) exist too. Support: Chrome 105, Firefox 110, Safari 16.

Positioning and stacking contexts

positionBehaviour
static (default)Normal flow. top/left and z-index do nothing.
relativeNormal flow, can be nudged, and is the anchor for absolute children.
absoluteOut of flow. Placed against the nearest ancestor that is not static, else the page.
fixedPlaced against the viewport.
stickyNormal flow until it would scroll past its top, then sticks inside its parent.

Stacking contexts

Definition: a stacking context is a group of elements ordered together as one unit along the z-axis (the depth towards the viewer). Inside it, children compare z-index only with each other. The whole group then takes one place in its parent's order. Everyday comparison: a folder of papers. Page 9999 is the top page of that folder, but a folder lying above it on the desk covers the whole thing.

A new stacking context is created by (the MDN list):

  • the root <html>
  • position: relative or absolute with a z-index other than auto
  • position: fixed or sticky
  • a flex or grid item with a z-index other than auto (no position needed)
  • opacity below 1
  • transform, filter, backdrop-filter, clip-path, mask, perspective
  • mix-blend-mode other than normal, and isolation: isolate
  • will-change naming a property from this list
  • contain: layout or contain: paint
  • top-layer elements (a modal <dialog>, a popover)
CSS
.card { opacity: .99; }                            /* creates a stacking context */.card .tooltip { position: absolute; z-index: 9999; }

The tooltip's 9999 only counts inside .card. An outside element with z-index: 2 is drawn above the whole card. I tested it: the 9999 child lost when its parent had opacity: .99 (or even will-change: opacity), and won without it.

How to debug: walk up the parents in DevTools until you find the one that creates a context. Then move the element out (a portal renders a React child elsewhere in the DOM, usually at the end of <body>), remove the property, or create a clean context on purpose with isolation: isolate on the component.

Keep a small named z-index scale (--z-dropdown, --z-modal) and create contexts on purpose. A "z-index war" with 99999 means nobody knows where the contexts are.

More tested facts:

  • z-index is ignored on a static element, but works on a flex or grid item without position.
  • A transform, filter or will-change: transform on a parent makes it the containing block for position: fixed children. A "fixed" header inside an animated wrapper then moves with it.
  • Why sticky fails: sticky sticks relative to the nearest ancestor that scrolls. An ancestor with overflow: hidden, auto or scroll becomes that scroller even if it never scrolls, so the element moved to -100px with the page. overflow: clip clips without becoming a scroller. Also check that top is set and the parent is taller than the element.

Responsive design

Definition: a page is responsive when it adapts to the space it has without a separate site. Mobile-first means the base CSS is for small screens, and min-width queries add layout for bigger ones.

CSS
:root { --step-0: clamp(1rem, 0.9rem + 0.5vw, 1.25rem); }body { font-size: var(--step-0); }.hero { min-height: 100dvh; padding-inline: clamp(1rem, 4vw, 3rem); }@media (width >= 48rem) {     /* range syntax: Chrome 104, Firefox 102, Safari 16.4 */  .layout { display: grid; grid-template-columns: 16rem 1fr; }}
  • clamp(min, preferred, max) gives the preferred value but never below the minimum or above the maximum. clamp(1rem, 2vw + .5rem, 2rem) measured 16px at 400px wide, 24px at 800px and 32px at 1600px. Keep rem in the preferred part, so text still grows when the user raises their font size. Pure vw text does not.
  • Viewport units. On phones the address bar shows and hides, so 100vh can be taller than the visible area. svh is the small viewport (bars shown), lvh the large one (bars hidden), and dvh the dynamic one that follows the bars. Use min-height: 100dvh for full-screen sections. On desktop all four are equal. Support: Chrome 108, Firefox 101, Safari 15.4.
  • Media queries are for the page frame and user settings (prefers-color-scheme, prefers-reduced-motion). Container queries are for components. I use both.
  • Helpers: aspect-ratio: 16 / 9, object-fit: cover, text-wrap: balance for short headings (Chrome 114, Firefox 121, Safari 17.5), and scroll-padding-top for anchor links under a sticky header.

Modern CSS you can use now

First version with support, from the MDN browser-compat data:

FeatureChromeFirefoxSafari
@layer999715.4
:has()10512115.4
Native nesting12011717.2
light-dark()12312017.5
content-visibility8512518
@scope11814626.4

:has() is a "parent selector": A:has(B) selects an A that contains (or is followed by) something matching B. Before it, you needed JavaScript.

CSS
.field:has(input:user-invalid) { --ring: crimson; }     /* style the wrapper */form:has(:invalid) button[type="submit"] { opacity: .5; }h2:has(+ p) { margin-block-end: .25rem; }               /* heading directly followed by a paragraph */

Keep the left side narrow: a broad body:has(.x) can be costly on busy pages.

Native nesting lets you write a rule inside another, with & standing for the parent. It is plain CSS and needs no Sass.

CSS
.card {  padding: 1rem;  & .title { font-weight: 600; }  &:hover { box-shadow: 0 2px 8px #0003; }  .dark & { background: #222; }  @media (width >= 40rem) { padding: 2rem; }}

Two differences from Sass. The suffix trick &-title (to make .card-title) does not work in native CSS; nothing matched in my test. And nested rules act like :is(), so they take the highest specificity of the parent selector list.

@scope (@scope (.card) to (.slot) { p { … } }) styles p inside .card but not inside .slot. All three engines support it only since 2026, so I know the idea and check my audience before I use it. CSS Modules and layers solve most of the same problems.

Animations and rendering performance

A frame goes through style, layout, paint and composite. Changing a property redoes its step and every step after it.

You animateSteps redoneCost
width, height, top, left, marginlayout, paint, compositeHigh
background-color, box-shadowpaint, compositeMedium
transform, opacitycomposite onlyLow; often off the main thread

So move with transform: translate() and fade with opacity. The compositor can run them even while JavaScript is busy.

  • will-change (will-change: transform) is a hint that tells the browser to prepare a separate layer early. In a test it created one extra composited layer. MDN says to use it as a last resort for a real problem and sparingly, since it uses memory. Add it just before the animation and remove it after. It also creates a stacking context.
  • content-visibility: auto lets the browser skip layout and paint for off-screen sections, which speeds up the first render of long pages. Add contain-intrinsic-size: auto 500px (an estimated height). Without it the skipped section had 0 height in my test (500px with it), and the scrollbar jumps. Skipped content stays in the accessibility tree and find-in-page.
  • Animating to height: auto does not work with a transition (mine jumped to the end). The portable trick: make the wrapper a grid and animate grid-template-rows from 0fr to 1fr, with overflow: hidden on the child (it measured 25px of 54px at the middle). interpolate-size works only in Chrome today.
  • Respect motion settings with @media (prefers-reduced-motion: reduce). Avoid reading layout (offsetHeight) and then writing styles in a loop (Debugging, Lesson 01).

CSS architecture

CSS is global: any rule can touch any element. Three common answers:

ApproachIdeaStrengthWeakness
BEM (Block, Element, Modifier)Naming rule: .card, .card__title, .card--featured.No tooling. Flat, low specificity.Long names. Needs discipline.
CSS ModulesEach .module.css file gets build-time unique class names.Real local scope, plain CSS.Sharing tokens and states needs thought.
Utility-first (Tailwind)Small one-purpose classes in markup (flex gap-2 p-4).Fast, consistent, small final CSS.Long class lists; needs component extraction.

In Next.js, a .module.css file imported as styles gives unique class names out of the box. The docs warn that final CSS order follows your import order and can differ between next dev and next build. So keep specificity low and check the production build.

Dark mode and print

Dark mode has two sources of truth: the system setting (prefers-color-scheme) and the user's saved choice.

CSS
:root { color-scheme: light dark; }   /* browser parts (scrollbars, form controls) match too */:root {  --bg: light-dark(#fff, #14161a);  --text: light-dark(#14161a, #eceff4);}:root[data-theme="light"] { color-scheme: light; }:root[data-theme="dark"]  { color-scheme: dark; }body { background: var(--bg); color: var(--text); }
  • light-dark(a, b) picks a colour for the active scheme. It reads color-scheme, so set that first. The data-theme rules let a user override the system.
  • Avoid the flash. Reading the saved choice in useEffect paints light first, then flips. Run a tiny inline script in the <head> that sets data-theme before first paint. The Next.js docs show this pattern, with suppressHydrationWarning on <html> since the script changes an attribute before React starts.
  • Tailwind v4's dark: follows prefers-color-scheme. For a manual toggle: @custom-variant dark (&:where(.dark, .dark *));.

Print: use @media print to hide navigation, use black on white, show link addresses (a[href^="http"]::after { content: " (" attr(href) ")"; }) and avoid breaks inside blocks (break-inside: avoid). Test with DevTools "Emulate CSS media type: print".

Typical debugging traps

SymptomLikely causeCheck or fix
My style does not applyA stronger selector, layer or !important; or a typo made the line invalid.DevTools Styles pane: crossed-out rules show who won.
z-index has no effectElement is static, or trapped in a parent's stacking context.Walk up for opacity, transform, filter, will-change.
Extra space above a sectionParent and child margins collapsed.display: flow-root, or gap.
Page scrolls sidewaysA wide child, 100vw, or a flex item with min-width: auto.Find the wide element; min-width: 0, max-width: 100%.
position: sticky does not stickAncestor has overflow: hidden/auto, or no top.Use overflow: clip; set top: 0.
Works in dev, differs in productionCSS import order changed in the build.Test next build; lower specificity; use layers.

Interview questions

What does "semantic HTML" mean and why does it matter? (must know)

It means using the element whose name matches the meaning: <nav>, <main>, <button>, <h2>, <label>. It matters for accessibility (the browser builds the accessibility tree from these elements, and screen-reader users move by landmarks and headings), for free behaviour (a <button> gets focus, Enter and Space with no code), and for search engines. A <div> with a click handler has none of this, so I would rebuild it with tabindex, key handlers and ARIA, and probably miss something.

How do you build an accessible form field with validation? (must know)

A visible <label for>, the right type and autocomplete, and required or pattern where it fits. The error is real text next to the field, linked with aria-describedby, with aria-invalid="true", and not only red. I style errors with :user-invalid, since :invalid is red before the user typed anything. Browser validation is comfort only. The server validates again, because anyone can send a request without my form. In JavaScript, input.validity says which rule failed, form.checkValidity() gives yes or no, and setCustomValidity("text") adds my own error until I clear it with an empty string. I add novalidate when I want my own messages or a schema library, for example for cross-field rules.

What is the difference between <script>, async and defer? (must know)

A plain script stops HTML parsing while it downloads and runs. async downloads in parallel and runs the moment it is ready, so the order is not fixed and the DOM may be incomplete. defer downloads in parallel and runs after parsing, in written order, just before DOMContentLoaded. Module scripts act like defer by default. I use defer for app code and async for independent things like analytics. Follow-up: "Do you still put scripts before </body>?" No. defer in the <head> is better, because the browser finds the file early.

LCP is bad and the hero is an <img>. What do you check? (senior signal)

First, is it loading="lazy"? That is the usual cause. I remove it and add fetchpriority="high". I check that the image is in the server HTML and not added late by JavaScript, that srcset and sizes serve the right size, and that the format is modern (AVIF or WebP). I set width and height to avoid layout shift. If it is a CSS background, the browser finds it late, so I use an <img> or preload it. Then I measure in the Performance panel (Browser, Lesson 09). In Next.js I use <Image fetchPriority="high">.

How is specificity calculated? Which wins: #a .b or eleven classes? (must know)

I count three columns: IDs, then classes, attributes and pseudo-classes, then element types, and compare left to right. #a .b is 1-1-0. Eleven classes is 0-11-0. The ID column is bigger, so #a .b wins. Inline style beats any selector and !important beats inline. * and combinators add nothing, :where() is zero, and :is(), :not() and :has() add nothing themselves but their strongest argument counts. Ties go to the later rule. But specificity is only compared after importance, origin and layers.

What problem do cascade layers solve? How can they go wrong? (senior signal)

They solve specificity wars. I declare the order once, for example @layer reset, base, components, utilities;, and a later layer wins no matter how specific the earlier selectors are. A utility class beats a component rule without !important, and third-party CSS can sit in a low layer. Gotchas: unlayered CSS beats all layers, so old CSS must be moved into a layer when I adopt them; and !important reverses the order, so an important rule in an earlier layer wins. Tailwind v4 is built on layers.

How are CSS custom properties different from Sass variables? (must know)

Sass variables are replaced at build time. Custom properties exist at run time. They follow the cascade and inheritance, so a child can redefine --gap for its subtree, a media query can change them, and JavaScript can set them with style.setProperty. That makes themes easy. Two gotchas. var(--x, fallback) uses the fallback only when --x is not defined. And if --x holds a value that is invalid for the property, the property falls back to inherited or initial, not to my earlier line. color: red; color: var(--bad) gave black.

Explain the box model, box-sizing and margin collapse. (must know)

Each element is content, padding, border and margin. With content-box, width: 200px plus 20px padding and 5px border gives a 250px box. With border-box it stays 200px, which is easier to plan, so I set it for everything. Margin collapse is when touching vertical margins merge into the larger one: siblings, a parent and its first or last child, and empty blocks. It happens only in block layout, only vertically, and never in flex or grid containers. To stop it I use display: flow-root on the parent, a border or padding, or gap.

Flexbox or grid? And why does a flex item overflow? (must know, with a senior follow-up)

Flexbox is for one dimension: a toolbar, a row of buttons, centring one thing. Grid is for two: page layouts, card galleries, forms with aligned labels. Short rule: flex lets the content decide, grid lets the layout decide. Overflow follow-up: a flex item has min-width: auto, so it will not shrink below its content. The fix is min-width: 0 on the item, plus overflow: hidden or overflow-wrap: anywhere.

Media queries or container queries? (senior signal)

Media queries look at the viewport, so they suit the page frame and user preferences. Container queries look at a parent's size, so they suit reusable components: the same card is a column in a narrow sidebar and a row in a wide area. I set container-type: inline-size on the parent and write @container (min-width: 30rem) { … }. Limits: a container query can style only descendants of the container, not the container itself, and container-type: size removes the content-based height.

Trap: "My dropdown has z-index: 9999 and still shows under another element." (trap)

A larger z-index only wins inside the same stacking context. A parent has probably created its own context, with opacity below 1, transform, filter, will-change, position: fixed or isolation: isolate. My 9999 is then compared only with siblings inside that parent, and the whole parent sits at its own level outside. I walk up the ancestors in DevTools to find it. Fixes: render the dropdown through a portal at the end of <body>, remove the property that made the context, or add isolation: isolate to the component on purpose.

Trap: "position: sticky does not stick." (trap)

Sticky sticks relative to the nearest ancestor that scrolls, and only inside its parent. I check that top is set. Then I look at ancestors for overflow: hidden, auto or scroll: any of them becomes the scroll container, even if it never scrolls, and the element then scrolls away. I tested this and the header moved to -100px. I replace overflow: hidden with overflow: clip, or remove it.

What do :has() and native nesting give you, and what are the traps? (senior signal)

:has() styles a parent or earlier sibling from what is inside or after it: .field:has(input:user-invalid). Before, I needed JavaScript. I keep the left side narrow, because broad :has() selectors can be costly. Nesting lets me put &:hover, .dark & and @media inside a rule. Traps: &-suffix does not work like in Sass, and nested rules act like :is(), raising specificity to the highest in the parent list. For @scope, all three engines only support it since 2026, so I check my audience first.

Which CSS is cheap to animate? What are will-change and content-visibility for? (senior signal)

transform and opacity are cheap, because the browser handles them in the compositing step, often off the main thread, with no layout or paint. Animating width, height, top or margin forces layout every frame. will-change: transform prepares a layer in advance. It is a last resort, costs memory and creates a stacking context, so I add it just before an animation and remove it after. content-visibility: auto skips layout and paint for off-screen sections. I add contain-intrinsic-size, since without it the section has zero height and the scrollbar jumps. I also respect prefers-reduced-motion.

BEM, CSS Modules or Tailwind for a large Next.js project? (senior signal)

I weigh the trade-offs. BEM needs no tooling but depends on discipline. CSS Modules give real local scope with plain CSS, but sharing tokens is up to me. Tailwind removes naming, keeps design values consistent and ships small CSS, but markup gets long and I must extract components to avoid repeating class lists. I would use Tailwind for most work, a CSS Module for the odd component that is awkward with utilities, and CSS variables for tokens. I keep selectors flat and avoid !important. Next.js orders CSS by import order, and dev can differ from build, so I test the production build.

How do you build dark mode with no flash of the wrong theme? (senior signal)

I set color-scheme: light dark so the browser's own UI matches, and define colours as variables with light-dark() or one set per data-theme. The system setting is the default, and a toggle saves the choice in localStorage. To avoid a flash I do not read the choice in useEffect, since the page would paint light first. I add a tiny blocking inline script in the <head> that sets data-theme on <html> before first paint. In Next.js I put it in the root layout with suppressHydrationWarning on <html>. With Tailwind v4 I override the dark variant with @custom-variant for a class or attribute toggle.

Key takeaways

  • Native HTML first: the right element, a label on every field, a visible focus ring, ARIA only when HTML cannot do the job.
  • Loading: defer for your scripts, an eager fetchpriority="high" LCP image, and a size on every image.
  • The cascade asks importance and layers first, then specificity (IDs - classes - types), then order. Unlayered CSS beats layers.
  • Layout: border-box; margin collapse is vertical and block-only; flex for one dimension, grid for two; min-width: 0 fixes many flex overflows.
  • Stacking: z-index works only inside a stacking context, and opacity, transform or will-change on a parent can trap it.
  • Responsive and fast: clamp() with rem, dvh, container queries for components; animate only transform and opacity.
  • Dark mode: color-scheme, light-dark() and an inline script to avoid the flash.

What's next

To see how these pieces connect to the full page load and Core Web Vitals, read Browser, Lesson 09. For the React side of accessibility, see Frontend Depth, Lesson 06.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series