Layout, specificity & modern CSS
Questions in this set 10
- 01Explain specificity, and why !important is almost never the answer.
- 02Grid or flexbox — how do you decide?
- 03What creates a stacking context, and why does my z-index: 9999 not work?
- 04Explain the box model and box-sizing.
- 05How do you build a responsive layout in 2026?
- 06Which modern CSS features have actually changed how you write it?
- 07How does CSS affect accessibility?
- 08How do you organise CSS in a large codebase?
- 09Debug this: the page scrolls horizontally on mobile and nothing looks wider than the screen.
- 10What is the difference between position: sticky and fixed, and why is my sticky element not sticking?
CSS gets treated as the easy part of a frontend interview and is where a surprising number of candidates come apart — usually because they have memorised properties without a mental model of the cascade or of formatting contexts. These are the questions that separate the two.
Explain specificity, and why !important is almost never the answer.
Specificity is a three-part tuple counted per selector: (IDs, classes/attributes/pseudo-classes, elements/pseudo-elements). Higher wins; ties go to whichever comes last in source order.
#nav .item a:hover /* (1,2,1) */
.nav .item a /* (0,2,1) */
a.item /* (0,1,1) */
a /* (0,0,1) */
* , :where(.anything) /* (0,0,0) — :where() always contributes ZERO */Things worth knowing beyond the arithmetic:
- Inline styles beat any selector;
!importantbeats inline; an!importantin a user stylesheet beats an author!important(an accessibility feature, not a bug). :is()takes the specificity of its most specific argument;:where()always contributes zero — which makes:where()the correct tool for library defaults you want consumers to override without a specificity war.- Cascade layers (
@layer) now sit above specificity in the cascade order: any rule in a later layer beats any rule in an earlier one regardless of specificity.@layer reset, base, components, utilities;is how modern codebases avoid!importantentirely.
!important loses because it is not composable — the only way to beat it is another !important, so one use propagates through the codebase. The legitimate uses are overriding third-party CSS you cannot edit, and utility classes that must always win (which is why Tailwind used it before layers existed).
Grid or flexbox — how do you decide?
Flexbox is one-dimensional: content flows along a single axis and sizes itself. Grid is two-dimensional: you define the tracks and place items into rows and columns.
The practical heuristic: if you are laying out a page or a component skeleton where the structure is known — a sidebar and content, a card grid, a form of label/field pairs — that is Grid. If you are distributing a row of items whose count or size you do not control — a nav bar, a toolbar, tags, a button group — that is flexbox.
/* Responsive card grid with no media queries at all */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
gap: 1rem;
}
/* The classic app shell */
.layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr); /* minmax(0,1fr), not 1fr — see below */
grid-template-areas: "sidebar main";
}The minmax(0, 1fr) detail is the one that earns points: 1fr means minmax(auto, 1fr), and auto means "at least min-content", so a wide child — a long unbreakable string, a <pre>, a table — expands the track and blows out the layout. minmax(0, 1fr) lets the track shrink and the child scroll. The flexbox equivalent is min-width: 0 on a flex item, for the same reason: flex items have min-width: auto by default.
What creates a stacking context, and why does my z-index: 9999 not work?
A stacking context is a self-contained layer group. A child can never escape its parent's stacking context, so an element with z-index: 9999 inside a context whose parent sits at z-index: 1 still renders below a sibling of that parent at z-index: 2. That is the answer to the question, and to most real z-index bugs.
Contexts are created by: the root element; position other than static **with a z-index other than auto``; position: fixedorsticky(always);opacityless than 1;transform, filter, perspective, backdrop-filter, will-change, contain: paint, isolation: isolate; and a flex/grid child with a z-index`.
That list explains the classic mystery: adding opacity: 0.99 or a transform for an animation silently creates a context and breaks a dropdown that used to overlay correctly.
Fixes, best first: render overlays in a portal at the document root (or use the top layer via <dialog> and popover, which sidestep stacking entirely); use isolation: isolate deliberately to contain a component's z-indexes; and keep a small documented z-index scale (--z-dropdown: 10; --z-modal: 100) instead of escalating numbers.
Explain the box model and box-sizing.
Content, then padding, then border, then margin. Under the default box-sizing: content-box, width sets the content width, so padding and border are added on top — width: 300px; padding: 20px; border: 1px occupies 342px. With box-sizing: border-box, width includes padding and border, which is what everyone actually wants:
*, *::before, *::after { box-sizing: border-box; }Related mechanics worth having ready: margin collapsing (adjacent vertical margins between block siblings collapse to the larger; a parent's margin collapses with its first/last child unless separated by padding, a border, or a new formatting context — and it does not happen in flex or grid containers, which is one reason gap is preferable), and the fact that percentage padding and margin resolve against the width of the containing block, including vertical ones — which is how the padding-top: 56.25% aspect-ratio hack worked before aspect-ratio existed.
How do you build a responsive layout in 2026?
Mobile-first, with as few breakpoints as possible, and increasingly with none:
/* Intrinsic sizing — no media query needed */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); }
.wrap { width: min(100% - 2rem, 65rem); margin-inline: auto; }
h1 { font-size: clamp(1.75rem, 1.2rem + 3vw, 3.5rem); }
/* Breakpoints when the design genuinely changes, min-width so mobile is the base */
@media (min-width: 48em) { … }
/* Container queries: respond to the COMPONENT's width, not the viewport's */
.card-host { container-type: inline-size; container-name: card; }
@container card (min-width: 30rem) {
.card { grid-template-columns: 8rem 1fr; }
}Container queries are the significant change, and worth volunteering: a card in a sidebar and the same card in a main column want different layouts, and viewport media queries cannot express that — which is why component libraries historically needed size props. Also mention logical properties (margin-inline, padding-block, inset) since they make right-to-left support nearly free, and dvh/svh/lvh units, which fix the mobile-browser address-bar problem that 100vh never solved.
Which modern CSS features have actually changed how you write it?
:has()— the long-awaited parent selector..card:has(img),form:has(:invalid) button { opacity: .5 },body:has(dialog[open]) { overflow: hidden }. It removes a large category of JavaScript.- Cascade layers — end specificity wars by architecture rather than by discipline.
- Container queries and
container-type, plus container query units (cqw). subgrid— child grids align to the parent's tracks, which finally makes card rows with matching internal rows possible.- Nesting, natively — no preprocessor needed for the common case.
aspect-ratio,gapin flexbox,inset,clamp().@property— typed custom properties, so you can animate a gradient or a colour custom property (which is otherwise not interpolatable).color-mix()and OKLCH colours — perceptually uniform lightness, so generating an accessible palette from one brand colour actually works.:focus-visible— focus rings for keyboard users without showing them on mouse click, which is what people were (incorrectly) usingoutline: noneto avoid.content-visibility: auto— skip rendering work for off-screen content; a genuine performance win on long pages.- View Transitions — animate between DOM states or across page navigations without a framework.
How does CSS affect accessibility?
Directly, and it is a growing interview topic:
- Contrast: WCAG AA needs 4.5:1 for body text, 3:1 for large text and for the non-text parts of UI components (borders of inputs, icon buttons). Check it, do not eyeball it.
- Focus visibility: never
outline: nonewithout an equally visible replacement.:focus-visiblegives you the ergonomics people actually wanted. prefers-reduced-motion: vestibular disorders are real, and large parallax or slide transitions cause nausea. Wrap non-essential animation in@media (prefers-reduced-motion: no-preference).- Order:
flex-direction: row-reverse,order, and grid placement change the visual order, not the DOM order — so tab order no longer matches what people see. That is a WCAG failure, and the fix is to reorder the DOM. display: noneandvisibility: hiddenremove content from the accessibility tree; hiding text visually while keeping it for screen readers needs the.sr-onlyclip pattern, notdisplay: none.- Respect user font sizes: use
remfor type and avoid fixed pixel heights on text containers, so a user's 200% zoom does not clip content. prefers-contrastand forced-colors mode (Windows High Contrast) — check that your UI is not conveying meaning only through a background colour that gets overridden.
How do you organise CSS in a large codebase?
Say what you have used and why it worked, then name the trade-off. The genuine options:
- Utility-first (Tailwind): no naming, no dead CSS, styles co-located with markup, a hard cap on file size. Costs: verbose class attributes, a build step, and a design system that must be expressed in config.
- CSS Modules: locally scoped class names with plain CSS, zero runtime. A good default for component codebases.
- BEM plus layers: works anywhere, no tooling, but relies entirely on team discipline.
- CSS-in-JS (styled-components, Emotion): dynamic styles from props, but a runtime cost and poor interaction with Server Components — which is why zero-runtime alternatives (vanilla-extract, Panda, Tailwind) have taken over in React Server Component apps.
Whatever the choice, the things that actually keep CSS maintainable are: design tokens as custom properties, a single source of truth for spacing/colour/type scales, cascade layers to make override order explicit, and deleting dead CSS aggressively.
Debug this: the page scrolls horizontally on mobile and nothing looks wider than the screen.
Something is wider — you just cannot see it because it is clipped or transparent. The usual suspects:
- An element with
white-space: nowrapor a long unbreakable string (a URL, a token, an inline<code>). Fix withoverflow-wrap: anywhereorword-break: break-word. - A grid or flex child at default
min-width: autorefusing to shrink below its content — theminmax(0, 1fr)/min-width: 0problem again. - A fixed
widthinpxon a container, orwidth: 100vw— which includes the scrollbar width on desktop and therefore overflows by 15px. Use100%or100dvwcarefully. - Negative margins, an absolutely positioned decorative element, or a
transform: translateXpushing something off-canvas to the right. - A wide
<table>,<pre>or embedded iframe with no scroll container.
Find it, rather than reaching for overflow-x: hidden — that hides the symptom, can break position: sticky inside, and leaves the real overflow in place:
// paste in the console: reports every element extending past the viewport
const vw = document.documentElement.clientWidth;
[...document.querySelectorAll("*")].filter(el => el.getBoundingClientRect().right > vw + 1);What is the difference between position: sticky and fixed, and why is my sticky element not sticking?
fixed is positioned relative to the viewport and removed from flow. sticky stays in flow and behaves relatively until it crosses a threshold in its scroll container, then behaves fixed — but only within the bounds of its parent element.
The three reasons sticky silently fails, in order of frequency:
- No threshold specified.
position: stickyneeds at least one oftop/bottom/left/rightset. Without it, it never sticks and produces no error. - An ancestor has
overflow: hidden,autoorscroll. That element becomes the scroll container, and the sticky element sticks inside it — usually invisibly. This is the most common cause, and it is often a container withoverflow: hiddenadded for an unrelated reason. - The parent is not tall enough. Sticky only travels within its parent's box, so a sticky item in a short wrapper appears not to move. Also, a flex/grid parent with default
align-items: stretchcan make the item exactly as tall as the container, leaving zero travel —align-self: startfixes it.