This page is for anyone facing a front-end round where HTML and CSS are tested on their own, from a first web job to a senior UI role. Most rounds start with semantic markup and accessibility basics, move to the box model, display and positioning, then test the cascade, specificity and stacking contexts. After that come flexbox, grid, centering and responsive design, and senior rounds add render performance, a real layout story and a judgement call. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Meaning: semantic elements say what the content is, not how it looks.
Who benefits: screen readers get landmarks to jump between, search engines get structure, other developers read it faster.
Free behaviour: native elements bring focus, keyboard support and roles without extra code.
"Semantic HTML means picking the element that describes what the content is. A div says nothing, but nav says this is a group of navigation links, main says this is the core content of the page, and header and footer mark the top and bottom areas. That matters because screen readers expose those as landmarks, so a blind user can jump straight to the main content instead of tabbing through the whole menu. Search engines also use the structure to understand the page. And it's easier for the next developer to read. The same goes for headings and buttons: I use h2 because it's a section heading, not because I want big text, and I use a real button because it's focusable and works with the keyboard for free. Styling is CSS's job, so the element choice should only be about meaning."
<body>
<header>...logo and site title...</header>
<nav aria-label="Main">...links...</nav>
<main>
<h1>Order history</h1>
...
</main>
<footer>...</footer>
</body>
Saying semantic tags are only for SEO, or that divs with classes are just as good because they look the same.
article: self-contained content that would still make sense on its own, like a post, a comment or a product card.
section: a themed part of a larger whole, normally with its own heading.
div: no meaning at all; right when you only need a wrapper for layout or styling.
"My test for article is: could this piece stand on its own, say in a feed or an email, and still make sense? A blog post, a forum comment or a news card passes that test. A section is a thematic chunk of something bigger, like the Reviews part of a product page, and it should usually have a heading, because a section with no heading is often just a div in disguise. And a div is fine whenever I only need a hook for layout, like a flex wrapper around two columns. I don't treat div as bad. Using section for a styling wrapper actually adds noise, because it claims meaning that isn't there. So the order I think in is: is there a specific element for this, like nav or aside? If not, is it a standalone article or a headed section? If neither, a div."
Replacing every div with section to look semantic, or not being able to say what makes content self-contained.
Purpose first: describe what the image means in context, not every pixel.
Decorative: use alt="" so screen readers skip it.
Never missing: without alt, a screen reader may read out the file name.
Links and buttons: if the image is the only content, alt names the action or destination.
"I ask what the image is doing on the page. If it carries information, the alt says that information, short and in context, so a product photo might be 'Blue canvas backpack, front view', and a chart's alt gives the key point, with the full data somewhere in the text. I don't start with 'image of', because the screen reader already announces it's an image. If the image is purely decorative, like a background swirl, I write alt with an empty value so assistive tech skips it. That's different from leaving alt off completely, because then some screen readers read out the file name, which is noise. And if an image is the only thing inside a link, like a logo linking home, the alt should say where it goes, like 'Home', not describe the logo."
<img src="backpack.jpg" alt="Blue canvas backpack, front view">
<img src="divider.svg" alt="">
<a href="/"><img src="logo.svg" alt="Home"></a>
Treating alt as an SEO keyword field, or not knowing that a missing alt and an empty alt behave differently.
What's missing: a div isn't focusable, doesn't respond to Enter or Space and isn't announced as a button.
Native first: a real button fixes all of that in one change.
ARIA's job: only changes what assistive tech is told; it adds no behaviour.
Good uses: states like aria-expanded, names for icon buttons, live regions for updates.
"A div with a click handler works for mouse users only. Keyboard users can't tab to it, pressing Enter or Space does nothing, and a screen reader doesn't announce it as a button, so people don't even know it's clickable. You could patch it with role button, tabindex zero and key handlers, but that's rebuilding what the button element already gives you. So my fix is just to use a button and reset its styles. The rule I follow is that ARIA changes what assistive tech is told, not how the element behaves, so bad ARIA can make things worse. I reach for it when HTML has no native way to express something: aria-expanded on a button that opens a menu, aria-label on an icon-only close button, or aria-live on a region where status messages appear after a save."
<!-- Before -->
<div class="btn" onclick="save()">Save</div>
<!-- After -->
<button type="button" class="btn" onclick="save()">Save</button>
<!-- Icon-only button needs a name -->
<button type="button" aria-label="Close dialog">X</button>
Saying you'd just add role="button" and move on, or believing ARIA attributes add keyboard behaviour on their own.
Label: a label tied to the input with for and id, or wrapped around it.
Why: gives the input an accessible name and a bigger click target.
Placeholder limits: it vanishes on typing, is often low contrast and isn't a reliable name.
Groups and help: fieldset and legend for radio groups, aria-describedby for hints and errors.
"Every input gets a real label element, either with a for attribute that matches the input's id or wrapped around the input. That gives the field an accessible name, so a screen reader says 'Email, edit text', and clicking the label focuses the input, which helps on small screens. A placeholder isn't a substitute: it disappears as soon as someone starts typing, so they forget what the field was for, the grey text is often hard to read, and assistive tech doesn't treat it as a dependable label. For a set of radio buttons, I wrap them in a fieldset with a legend, so the question gets read along with each option. And for hints or error messages, I link them to the input with aria-describedby, so they're announced when the field gets focus."
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email"
aria-describedby="email-hint">
<p id="email-hint">We only use this for receipts.</p>
<fieldset>
<legend>Delivery speed</legend>
<label><input type="radio" name="speed" value="std"> Standard</label>
<label><input type="radio" name="speed" value="fast"> Express</label>
</fieldset>
Designing forms with placeholders as the only labels, or never having heard of fieldset and legend.
Four layers: content, padding, border, margin, from the inside out.
content-box (default): width sets only the content; padding and border are added on top.
border-box: width includes padding and border; margin is always outside.
Common reset: apply border-box to everything so sizes match the design.
"Every element is a box with four layers: the content in the middle, padding around it, then the border, then margin outside, which is the space between it and its neighbours. By default box-sizing is content-box, so the width I set only applies to the content. If I say width 300 pixels with 20 pixels of padding and a 1 pixel border, the element actually takes 342 pixels, which is where a lot of 'why does this overflow' bugs come from. With border-box, the 300 pixels includes the padding and border, and the content shrinks to fit inside. Margin is never part of the width in either mode. Most projects set border-box on every element and its pseudo-elements, because it makes sizes match what designers expect."
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 300px;
padding: 20px;
border: 1px solid #ccc; /* still 300px wide in total */
}
Saying margin is part of the element's width, or not knowing which box-sizing value is the default.
The rule: vertical margins between block boxes in normal flow collapse to the larger one.
Parent and child: a first child's top margin can escape through a parent with no border, padding or content in between.
When it stops: flex and grid items, floats, absolute positioning, or a parent that starts a new formatting context.
"That's margin collapsing. In normal block flow, when two vertical margins touch, the browser uses the larger of the two instead of adding them, so two 20 pixel margins give a 20 pixel gap. It only happens vertically, never on left and right margins. The case that confuses people more is parent and child: if a parent has no border, padding or content above its first child, the child's top margin collapses through and pushes the whole parent down, so it looks like the parent has the margin. It stops happening for flex and grid items, for floated or absolutely positioned elements, and when the parent creates a new block formatting context, for example with display flow-root. Adding a little padding or a border to the parent also stops the parent-child case. In practice I often avoid it by spacing children with gap in a flex or grid container."
Blaming a browser bug, or fixing it by stacking extra spacer divs without knowing why the gap appeared.
block: starts on a new line, fills the available width, respects width and height.
inline: flows inside text; width and height are ignored and vertical margins don't move lines.
inline-block: flows inline like a word but is sized like a block.
Related: display none removes the box, visibility hidden keeps its space.
"A block element, like a div or a paragraph, starts on its own line and stretches to fill the width of its container, and it respects width, height and all margins. An inline element, like a span or a link, sits inside a line of text and is only as wide as its content. Width and height don't apply to it, which is why a width on a span seems to do nothing, and vertical margins don't push the surrounding lines apart. Inline-block is the middle ground: it sits in the line like a word, but it has a real box, so width, height and vertical padding all work. That's handy for things like badges or buttons inside a sentence. These days, if I need sizing on a span, I'll often make it inline-block or let a flex parent handle it."
Saying inline and inline-block are the same, or not knowing that width is ignored on inline elements.
static and relative: static is normal flow; relative nudges the element but keeps its original space.
absolute: out of the flow, placed against the nearest positioned ancestor.
fixed: placed against the viewport, unless an ancestor has a transform or filter.
sticky: acts relative until a threshold, then sticks inside its scroll container.
"Static is the default: the element just sits in normal flow and top or left do nothing. Relative keeps the element's space in the flow but lets me shift it visually, and it also becomes the anchor for absolutely positioned children. Absolute takes the element out of the flow and positions it against the nearest ancestor whose position isn't static, which is why I put position relative on a card before placing a badge in its corner. Fixed positions against the viewport so it stays put on scroll, with one trap: a transform or filter on an ancestor makes it position against that ancestor instead. Sticky behaves like relative until you scroll past a threshold, then it sticks. When sticky seems broken, it's usually one of three things: no top value was set, an ancestor has overflow hidden or auto so it sticks inside that box instead, or the parent is only as tall as the sticky element, so there's no room to stick."
.card { position: relative; }
.card .badge { position: absolute; top: 8px; right: 8px; }
.table-head {
position: sticky;
top: 0; /* without a threshold, sticky never sticks */
}
Saying absolute positions against the page or the direct parent, without mentioning the nearest positioned ancestor.
Stacking context: z-index only compares elements inside the same context.
What creates one: a positioned element with a z-index, opacity below 1, transform, filter, isolation isolate and more.
The trap: a child's huge z-index is capped by its parent context's place in the stack.
Fix: find the context-creating ancestor; move the dropdown out, often to the end of body, or fix the ancestor's z-index.
"z-index isn't global. It only orders elements within the same stacking context. So if the dropdown lives inside a container that forms its own stacking context, and that container sits below the header, the whole container is painted below the header as one unit, and 9999 only wins against its siblings inside that container. Stacking contexts get created by more than people expect: a positioned element with a z-index other than auto, anything with opacity below 1, a transform, a filter, will-change on those properties, and flex or grid children that have a z-index. To debug, I walk up the dropdown's ancestors in dev tools looking for one of those properties. The fix is either to raise that ancestor's level against the header, remove the property that created the context, or render the dropdown outside that tree, usually at the end of body, which is why many libraries use a portal for menus and modals."
.header { position: sticky; top: 0; z-index: 10; }
.sidebar { transform: translateX(0); } /* new stacking context, level auto */
.sidebar .dropdown {
position: absolute;
z-index: 9999; /* only beats siblings inside .sidebar */
}
Suggesting a bigger z-index, or saying z-index only needs position set without mentioning stacking contexts.
Three columns: IDs, then classes, attributes and pseudo-classes, then elements and pseudo-elements.
Compare left to right: one ID beats any number of classes.
Adds nothing: the universal selector and combinators; :where() counts zero.
Above all selectors: inline styles, and !important above those.
"I think of specificity as three columns. The first counts IDs, the second counts classes, attribute selectors and pseudo-classes, and the third counts element names and pseudo-elements. You compare column by column from the left, so one ID beats any number of classes. For hash nav a, that's one ID and one element, written as one, zero, one. For dot menu dot item a, it's zero, two, one. The ID one wins even though the second has more parts. An inline style attribute beats both, because it sits above selector specificity altogether, and only a declaration marked important in a stylesheet can override it. Combinators and the universal selector add nothing. :is and :not count as their most specific argument, while :where always counts zero. If two rules tie, the one that comes later in the CSS wins."
Adding up selectors as a single number where eleven classes beat one ID, or saying the longer selector always wins.
Order of checks: origin and importance, then inline styles, then layers, then specificity, then source order.
Origins: browser defaults, user styles and author styles; !important changes their ranking.
Layers: a later layer beats an earlier one regardless of specificity; unlayered styles beat all layers.
Separate idea: inheritance only applies when no rule sets the property on that element.
"Specificity is only one step. The cascade first looks at where a declaration comes from, browser defaults, user styles or the site's own CSS, and whether it's marked important. Then an inline style beats selector rules. Then it checks cascade layers, then specificity, and only if everything ties does the later rule win. Layers are the newer piece. With @layer I can declare an order, say reset, then a third-party library, then components, and a rule in a later layer beats one in an earlier layer even if the earlier one has a far stronger selector. Styles not in any layer beat all layered styles. That's great for taming a component library: put it in a low layer and my simple class selectors override it without fighting. Important flips things: important declarations in an earlier layer beat ones in later layers, which is deliberate, so base styles can protect something critical."
@layer reset, vendor, components;
@import url("datepicker.css") layer(vendor);
@layer components {
.btn { background: navy; } /* beats any selector inside vendor */
}
Describing the cascade as only specificity and source order, or treating !important as the normal way to win.
Pseudo-class (one colon): selects an element in a state or position, like :hover, :focus-visible, :nth-child().
Pseudo-element (two colons): styles a part of an element or adds a generated box, like ::before, ::placeholder.
Gotcha: ::before and ::after need a content property to appear.
"A pseudo-class selects an existing element when it's in a certain state or position, so :hover for when the pointer is over it, :focus-visible for keyboard focus, :disabled, or :nth-child for every other row in a table. A pseudo-element targets something that isn't a separate element in the markup: a part of the element, like ::first-line or ::placeholder, or a generated box, like ::before and ::after. The modern syntax uses one colon for pseudo-classes and two for pseudo-elements. A practical example of each: I use :focus-visible to show a clear focus ring for keyboard users without showing it on every mouse click, and I use ::after to draw a small arrow on a tooltip without adding an extra span. The catch with ::before and ::after is that they don't render unless you set content, even to an empty string."
tr:nth-child(even) { background: #f6f6f6; }
.btn:focus-visible { outline: 3px solid #1a5fb4; }
.tooltip::after {
content: "";
position: absolute;
border: 6px solid transparent;
border-top-color: #333;
}
Mixing the two up, or putting real, important text inside ::before where screen readers may not treat it reliably.
Runtime vs build time: Sass variables are replaced at compile time; custom properties exist in the browser.
They cascade: they inherit and can be redefined per element, per media query or from JavaScript.
Theming: define tokens on :root, redefine them for dark mode, and components only read the tokens.
Fallbacks: var() takes a second value used when the property isn't set.
"Sass variables vanish at build time: the compiler swaps in the value and the browser never knows a variable existed. Custom properties, the ones starting with two dashes, are real properties in the browser. They inherit and cascade like any other property, so I can redefine one on a specific element, inside a media query, or change it from JavaScript, and everything reading it updates live. That's exactly what theming needs. I'd define colour tokens on :root, like background, text and accent, and have components use only those tokens through var(). For dark mode I redefine the same tokens inside a prefers-color-scheme dark media query, and also under a data-theme attribute so a user can override the system setting. No component CSS changes at all. I'd also set color-scheme so built-in controls and scrollbars match."
:root {
--bg: #ffffff;
--text: #1c1c1c;
--accent: #1a5fb4;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #121212; --text: #eeeeee; --accent: #8ab4f8; }
}
:root[data-theme="dark"] { --bg: #121212; --text: #eeeeee; --accent: #8ab4f8; }
body { background: var(--bg); color: var(--text); }
.link { color: var(--accent, blue); }
Saying custom properties are just native Sass variables, or theming by duplicating every component rule for dark mode.
basis: the starting size before free space is shared out.
grow: how much of the leftover space an item takes, as a ratio.
shrink: how much an item gives up when there isn't enough room.
Shorthands: flex 1 is grow 1, shrink 1, basis zero; flex auto uses basis auto; flex none freezes the item.
"Flex-basis is the item's starting size along the main axis before the leftover space is handed out. Flex-grow is a ratio for sharing extra space: if one item has grow 2 and another has grow 1, the first gets twice as much of the extra, not twice the total width. Flex-shrink is the same idea in reverse, for when items don't fit. The default for a flex item is grow zero, shrink one, basis auto, so items sit at their content size and only shrink. Flex 1 sets grow 1, shrink 1 and a basis of zero, so all the space is treated as free space and items with flex 1 come out equal widths whatever their text length, as long as the content fits. Flex auto keeps basis auto, so bigger content ends up with a bigger item. Flex none means don't grow or shrink, which I use for icons or fixed sidebars."
.toolbar { display: flex; gap: 8px; }
.toolbar .search { flex: 1; } /* takes all spare room */
.toolbar .icon { flex: none; } /* never squashed */
.cols > * { flex: 1; } /* equal columns */
.cols > .wide { flex: 2; }
Saying flex-grow 2 always makes an item twice as wide as the others whatever its basis, or not knowing the default flex values.
Cause: flex items default to min-width auto, so they won't shrink below their content's width.
Fix: set min-width: 0 on the flex item, or give it overflow hidden, which also drops the automatic minimum.
Ellipsis needs: white-space nowrap, overflow hidden and text-overflow ellipsis on the text box.
Grid twin: a 1fr column has the same problem; minmax(0, 1fr) fixes it.
"The usual cause is that flex items have a default min-width of auto, which for flex items means they won't shrink smaller than their content. A long unbroken file name has a large content width, so the item refuses to shrink and pushes everything wider, and the ellipsis never kicks in because the box is never narrower than the text. The fix is to give that flex item min-width zero, which lets it shrink, and then the ellipsis rules can work: white-space nowrap, overflow hidden and text-overflow ellipsis on the element holding the text. If the text is nested deeper, every flex item on the way down may need min-width zero too. Grid has the same trap: a 1fr column really means minmax auto 1fr, so a wide code block can blow it out, and minmax zero 1fr fixes it."
.row { display: flex; gap: 12px; }
.row .name {
flex: 1;
min-width: 0; /* allow shrinking below content size */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); }
Setting a fixed pixel width on the text to force it, or trying overflow hidden on the parent only.
Flexbox: one direction at a time; content decides the sizes.
Grid: rows and columns together; the layout decides where content goes.
Flex examples: a toolbar, a row of tags, a button with an icon.
Grid examples: a page shell, a card gallery, a form where labels and fields must line up.
"My rough rule is that flexbox is for one direction and grid is for two. Flexbox lays items out in a row or a column and lets their content push the sizes around, so it's perfect for a navigation bar, a set of tags that wrap, or a button with an icon and text. When things wrap in flexbox, each line is laid out on its own, so items on different lines don't line up as columns. Grid defines rows and columns up front, and items snap into that structure, so it's the better fit when things need to align both ways: a page shell with header, sidebar and main, a gallery of cards, or a form where every label column lines up. I use them together all the time: grid for the page and card layout, flexbox inside each card for the small rows."
Saying grid replaces flexbox, or that the choice is only personal taste.
repeat with auto-fill: creates as many columns as fit.
minmax: each column is at least the minimum, and shares leftover space with 1fr.
auto-fill vs auto-fit: auto-fit collapses empty tracks, so a few cards stretch to fill the row.
Edge case: wrap the minimum in min() so one card never overflows a very narrow screen.
"I'd use grid with repeat, auto-fill and minmax. repeat auto-fill tells the browser to make as many column tracks as will fit in the container. minmax 220 pixels 1fr says each column must be at least 220 pixels wide but can grow to share the leftover space equally. So on a wide screen you might get five columns, and as the container narrows it drops to four, three, down to one, with no media queries at all. Gap handles the spacing. The difference between auto-fill and auto-fit shows when there are only a couple of cards: auto-fill keeps empty column tracks, so the cards stay card-sized, while auto-fit collapses the empty tracks and the cards stretch across the row. One edge case: on a screen narrower than 220 pixels, the card would overflow, so I wrap the minimum in min() with the container's full width."
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: 16px;
}
Writing a media query for every breakpoint by hand, or mixing up auto-fill and auto-fit without being able to say what changes.
Grid: place-items center on the parent, the shortest version.
Flexbox: justify-content and align-items center, good when the parent is already a flex row or column.
Absolute: for an overlay, inset 0 with margin auto on a sized box, or offset and transform.
Horizontal only: margin auto on a block with a width, or text-align center for inline content.
"The shortest way today is grid on the parent with place-items center, which centers the child on both axes. If the parent is already a flex container, I'd use justify-content center and align-items center instead. Either way the parent needs a height for vertical centering to show, like a min-height, because a parent that's only as tall as its content has nothing to center within. For something that has to float above other content, like a loading spinner over a panel, I'd position the parent relative, the child absolute with inset zero and margin auto, which works when the child has a set size. You'll also see older code that sets top and left to the middle and pulls the box back with translate minus half, which works for unknown sizes. For horizontal centering only, margin auto on a block with a width still works fine."
/* 1. Grid */
.parent { display: grid; place-items: center; min-height: 100vh; }
/* 2. Flexbox */
.parent { display: flex; justify-content: center; align-items: center; }
/* 3. Overlay */
.panel { position: relative; }
.spinner { position: absolute; inset: 0; margin: auto; width: 40px; height: 40px; }
Only knowing a fixed negative-margin hack, or using line-height tricks for a box that isn't a single line of text.
Viewport tag: tells phones to use the device width instead of a wide desktop-sized layout scaled down.
Mobile first: base styles for small screens, then min-width media queries add layout as space grows.
Breakpoints: set them where the content breaks, not by device names.
Beyond width: media queries can also check hover, pointer, reduced motion and colour scheme.
"Without the viewport meta tag, most phone browsers assume the page was built for desktop, lay it out at a wide width and shrink it down, so text is tiny and my media queries never match the phone's real width. Setting width equals device-width and initial-scale 1 fixes that. Mobile-first means the base CSS, outside any media query, is the small-screen layout, usually a single column, and then I add min-width media queries that layer on columns and extras as the screen gets wider. It tends to keep CSS smaller, because phones don't load a desktop layout only to undo it. For breakpoints I resize the browser and add one where the design actually starts to look wrong, rather than picking a list of device widths. I also use media features like prefers-reduced-motion to turn off big animations."
<meta name="viewport" content="width=device-width, initial-scale=1">
.layout { display: grid; gap: 16px; } /* phones: one column */
@media (min-width: 48em) {
.layout { grid-template-columns: 240px 1fr; } /* sidebar appears */
}
@media (prefers-reduced-motion: reduce) {
* { animation: none; transition: none; }
}
Designing breakpoints around specific phone models, or never having heard of the viewport meta tag.
px: fixed; fine for borders and small details.
rem: relative to the root font size, so it follows the user's browser setting and doesn't compound.
em: relative to the element's own font size, so it compounds when nested; good for spacing that scales with text.
vw and vh: a hundredth of the viewport; useful for fluid sizes, risky for text alone.
"Pixels are fixed, so I keep them for things like one-pixel borders. rem is relative to the root element's font size, and that root size comes from the user's browser setting unless I override it. So if someone bumps their default text size up for readability, everything in rem scales with it, which is the main accessibility reason teams use rem for font sizes. em is relative to the current element's font size, which makes it handy for padding on a button that should grow with its text, but it compounds: an em font size inside another em font size multiplies, which bites in nested lists. vw is a hundredth of the viewport width, good for fluid layouts. I avoid sizing text in vw alone, because it doesn't grow when the user zooms in, so I use clamp with a rem minimum and maximum around it."
html { font-size: 100%; } /* respect the user's setting */
h1 { font-size: clamp(1.75rem, 1rem + 3vw, 3rem); }
.btn {
font-size: 1rem;
padding: 0.5em 1em; /* grows if the button text grows */
}
Setting the root font size in pixels and saying rem is just a pixel alias, or not knowing that em compounds.
srcset and sizes: list image files by width and tell the browser how wide the image will display; it picks the best file.
picture: for different crops per screen, or newer formats with a fallback.
No jumping: width and height attributes, or aspect-ratio, reserve the space before the image arrives.
Loading: lazy-load images below the fold, never the main hero image.
"For the same image at different resolutions I use srcset with width descriptors, listing, say, a 480, 960 and 1600 pixel version, plus a sizes attribute that tells the browser how wide the image will be shown at each layout. The browser combines that with the screen's pixel density and picks the smallest file that still looks sharp. If I need a different crop on phones, or want to offer a newer format with a fallback, I use the picture element with source children. To stop the page jumping, I always put width and height attributes on the img. The browser uses them to work out the aspect ratio and reserves the space before the file arrives, even when CSS makes the image fluid. And I add loading lazy to images further down the page, but not to the hero image, because delaying that makes the main content show up later."
<img
src="shoe-960.jpg"
srcset="shoe-480.jpg 480w, shoe-960.jpg 960w, shoe-1600.jpg 1600w"
sizes="(min-width: 60em) 50vw, 100vw"
width="1600" height="1200"
alt="Red running shoe, side view"
loading="lazy">
Serving one huge image to every device and shrinking it with CSS, or lazy-loading the hero image.
Why it blocks: the browser won't paint until it has the stylesheets in the head, to avoid showing unstyled content.
Also blocks scripts: scripts after a stylesheet wait for it, because they might read styles.
Fixes: inline the small critical CSS, load the rest without blocking, split CSS by media type.
Avoid: chains of @import, huge unused bundles and slow third-party stylesheets in the head.
"The browser builds the DOM from HTML and a style model from CSS, and it needs both to lay out and paint. If it painted before a stylesheet in the head arrived, you'd get a flash of unstyled content, so by default it waits, and the stylesheet blocks rendering. It also holds back scripts that come after it, in case they ask for computed styles. So a slow or huge stylesheet directly delays the first paint. What I'd do: measure first in the performance panel. Then keep the head's CSS small, inline the critical styles needed for the top of the page, and load the rest in a way that doesn't block. A link with a media attribute that doesn't match, like print, downloads without blocking. I'd remove @import chains, since each one is found only after the previous file downloads, cut unused CSS, and make sure it's compressed and cached."
<head>
<style>/* small critical CSS for the first screen */</style>
<link rel="stylesheet" href="/main.css">
<!-- Downloads without blocking the first paint -->
<link rel="stylesheet" href="/print.css" media="print">
</head>
Saying CSS doesn't block anything because it's not JavaScript, or moving all stylesheets to the bottom of the body.
Head: a unique title, a meta description for the snippet, a canonical link, the lang attribute.
Body: one clear main heading, a logical heading order, real links with href, useful alt text.
Structure: semantic elements and, where it fits, structured data for things like products or articles.
Crawlable: content present in the HTML or rendered reliably, not hidden behind clicks.
"In the head, every page gets its own title that says what the page is about, because that's usually the blue link in the results, and a meta description, which doesn't directly boost ranking but often becomes the snippet people read before clicking. I add a canonical link when the same content is reachable at several URLs, so search engines know which one to show, and a lang attribute on the html element. In the body, I use one clear main heading and a sensible heading order, since that's how both crawlers and screen readers understand the outline. Links should be real anchor tags with an href, not click handlers on spans, or crawlers may not follow them. Images get alt text. And for things like products, articles or breadcrumbs, I add structured data in JSON-LD so the page can qualify for richer results."
<html lang="en">
<head>
<title>Waterproof Hiking Boots for Men | Example Shop</title>
<meta name="description" content="Light, waterproof boots with free returns.">
<link rel="canonical" href="https://example.com/boots/men-waterproof">
</head>
Talking about stuffing keywords into meta tags, or claiming the meta keywords tag still helps.
The bug: what users saw, where and how it was reported.
Isolate: reproduce on the real device or engine, then cut the page down to the smallest case.
Root cause: the specific property or behaviour that differed.
Fix and guard: the change, and how you checked it on other browsers.
"At my last company, support reported that the checkout button on our cart page was hidden behind the phone's toolbar, but only on some phones. It looked fine in desktop dev tools. I reproduced it on a real phone and found the cart panel used a height of 100vh with the button pinned at the bottom. On that mobile browser, 100vh was measured as if the address bar was hidden, so the panel was taller than the visible area and the button sat under the toolbar. I cut it down to a small test page to confirm that was the only cause. The fix was to use the dynamic viewport unit, dvh, with vh as a fallback line above it for older browsers. I then checked it on the phones and browsers our analytics showed most, and added a note to our CSS guidelines about viewport height on mobile."
A story that ends with adding browser-specific hacks until it looked right, with no idea of the actual cause.
Trigger: an audit, a user complaint or your own testing.
How you tested: keyboard only, a screen reader, an automated checker, contrast checks.
Fixes: the specific markup and CSS changes.
Lasting change: what you added so the next feature didn't repeat it.
"In my final-year project, we built a booking site, and before the demo I spent an afternoon using it with only the keyboard and then with a screen reader. It was humbling. The time-slot picker was a set of divs with click handlers, so it couldn't be reached by keyboard at all. Our custom CSS had removed the focus outline, so even working controls were invisible when tabbed to. And the form fields used placeholders instead of labels, so the screen reader just said 'edit text' for each one. I rebuilt the slots as radio buttons inside a fieldset, styled to look the same, which gave us keyboard and screen reader support for free. I added a clear focus-visible style and proper labels. I also ran an automated checker, which caught a few low-contrast text colours. After that we added a keyboard pass to our checklist for every new screen."
Only mentioning running an automated tool, or treating accessibility as adding ARIA attributes after the fact.
The mess: what the symptoms were and what they cost the team.
Safety net: how you avoided visual regressions, such as screenshot tests or page-by-page checks.
Approach: small steps: tokens, lower specificity, scoping, deleting dead rules.
Result: what got easier, and the rules you put in place.
"At my last company, the main stylesheet had grown over years. Every new feature added a more specific selector or an important to beat the last one, and changing a button colour meant hunting down a dozen overrides. I didn't try a rewrite. First I set up screenshot tests on the key pages, so any visual change would show up in review. Then I went in small steps. I pulled repeated colours and spacing into custom properties. I moved the old global rules into a low cascade layer, so new component styles could win with plain class selectors instead of escalating. I rewrote the worst ID-based selectors one component at a time, and used the coverage panel across the key pages to find rules nothing matched any more, removing them in small batches. Over a few months the important count dropped to almost nothing, and we agreed a rule in code review: no new important without a comment saying why."
Describing a big-bang rewrite with no safety net, or a story where the fix was adding even more specific selectors.
Understand: why the overrides lose now, specificity, source order or inline styles from the widget.
Better options: load order, a scoped wrapper class, cascade layers, or the widget's own theming hooks.
Pragmatic call: if important is truly needed, keep it contained in one clearly commented file.
Follow-up: a ticket to clean it up after the release.
"I wouldn't just say no, because the release matters. I'd first sit with them for ten minutes and look at why the rules are losing. Often the widget's CSS loads after ours, so simply loading ours later fixes it. Or the vendor uses long chained class selectors, and prefixing ours with the widget's wrapper class is enough to beat them. If the widget exposes theming options or custom properties, that's the cleanest route. With cascade layers, we can put the vendor stylesheet in a low layer and our plain rules win. The one case where important really is the answer is when the widget writes inline styles, since nothing else beats those. If we end up there, I'd ask to keep every override in one file scoped under the widget's wrapper, with a comment explaining why, and a ticket to revisit after the release, so it doesn't leak into the rest of the codebase."
Either approving a dozen scattered important rules without a question, or blocking the release over style purity.
Name the cost: a div dropdown must rebuild keyboard support, focus, screen reader roles and mobile behaviour.
Options: style the native select as far as the design allows, use a tested accessible component, or build it properly with more time.
Negotiate: show design what can ship Friday and what needs longer.
Protect users: never ship a control that only works with a mouse.
"I'd go back to the designer early, not on Thursday night. A native select gives us keyboard support, typing to jump to an option, screen reader announcements and the phone's own picker, all for free. A stack of divs gives us none of that, and rebuilding it properly, with the right roles, arrow keys, focus handling and escape to close, is days of careful work plus testing. So I'd offer options. Plan A: style the native select's closed state to match the design, which covers most of the look, and ship it Friday. Plan B: if the open list really must be custom, use a well-tested accessible component the team already trusts. Plan C: build our own, but after the release with proper time. What I won't do is ship a div dropdown that mouse users can use and keyboard or screen reader users can't, because that quietly locks people out of the form."
Building the div version as asked with no mention of keyboard or screen reader users, or refusing flatly without offering an alternative.
Find the hard parts: tables, wide media, long words, fixed widths and hover-only features.
Propose fixes: scrolling table wrappers or card layouts, fluid images, wrapping text.
Check quickly: a short call or annotated screenshots with the designer before building it all.
Test real: real phones or device modes at small widths, not only a resized desktop window.
"'Just stack' works for most sections, but some things don't stack, so I'd list those first: the data tables, the wide images, any fixed-width boxes and anything that only works on hover. Then I'd sketch a fix for each. For a table with many columns, either a wrapper that scrolls sideways with a visible hint, or turning each row into a card if only a few columns matter on mobile. Images get a max-width so they scale down. Long words or URLs get overflow-wrap so they break instead of pushing the page wide. Hover menus need a tap version. I'd take quick screenshots of my proposals to the designer, because a five-minute check now is cheaper than rebuilding later. Then I'd test on a real phone as well as dev tools, and add a note to the ticket so the next mockup includes a mobile view."
Shipping the broken mobile layout because the mockup didn't cover it, or redesigning everything without talking to design.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.