Frontend Fundamentals: HTML, CSS, and Responsive Styling Questions
Core web-platform implementation skills: semantic HTML and document structure, native controls versus ARIA-patched divs (button versus link), accessible markup (labels, headings, landmarks, image alt text, icon-only buttons, keyboard operability, skip links, ARIA basics, focus styles), the CSS box model, cascade and specificity (including cascade layers and taming !important), selector matching cost, display types, positioning (static, relative, absolute, fixed, sticky) and stacking contexts and z-index, flexbox and grid layout (including subgrid and masonry options), units and fluid sizing (rem, clamp, viewport units including dynamic viewport units), the viewport meta tag, media queries, mobile-first breakpoints and how to choose them, container queries, responsive images (srcset, sizes, picture, aspect-ratio), touch target sizing, right-to-left and long-text layouts, custom properties and light/dark theming, and CSS organization (BEM, CSS Modules, utility-first, Sass). Includes debugging layout bugs, progressive enhancement, @supports and cross-engine CSS fallbacks. Performance measurement, building full interactive widgets and form flows, design-system governance, WCAG audit strategy, design handoff, visual regression testing and cross-browser test coverage are covered elsewhere.
What size should touch targets be on mobile, and why? How would you keep a dense desktop toolbar usable on touch screens without redesigning it?
Sample Answer
Direct answer
Aim for 44 by 44 CSS pixels for anything a thumb must hit, and treat 24 by 24 CSS pixels as the floor. Both numbers come from WCAG 2.2 (the Web Content Accessibility Guidelines). WCAG sorts its criteria into three conformance levels: A (lowest), AA (the level most legal and procurement requirements ask for) and AAA (highest, the strictest). Success Criterion 2.5.8 (Level AA) says "The size of the target for pointer inputs is at least 24 by 24 CSS pixels, except when:" a list of exceptions, and 2.5.5 (Level AAA) says "The size of the target for pointer inputs is at least 44 by 44 CSS pixels except when:" its own exceptions. A CSS pixel is a layout unit, not a device pixel, so these sizes survive high-density screens and browser zoom.
To keep a dense desktop toolbar usable without redesigning it, leave the markup and the fine-pointer styles alone and add one @media (pointer: coarse) block that enlarges the buttons, plus flex-wrap: wrap on the toolbar (the flexbox setting that lets items drop onto a new row when they no longer fit) so the bigger buttons break onto a second row. Without it a flex row does not overflow: its items shrink to fit the bar, so the buttons end up smaller than 44px. A "fine" pointer is an accurate one such as a mouse; a "coarse" pointer is a finger on a touchscreen; the browser reports which one the primary input device is through the pointer media feature, a CSS condition about the user's device rather than the window size.
Why the size matters
A fingertip is a much blunter pointer than a mouse cursor, so on a small button it is easy to miss or to hit the neighbour. WCAG states the intent of 2.5.8 as "to help ensure targets can be easily activated", with the full sentence continuing to say without accidentally activating an adjacent target. Two things follow:
- Size and spacing trade off. The 2.5.8 text allows undersized targets when they are spaced so that a 24 CSS pixel circle centred on each does not intersect another target. A dense toolbar can therefore pass AA with small buttons only if they are spread out, and enlarging the buttons is the other way to the same goal. Worked example from WCAG's own explanation of the criterion: the circle has a 12px radius, so the centres of two undersized buttons must be at least 24px apart. Two 20px buttons with a 4px gap have centres 20 + 4 = 24px apart, so the circles only touch and the layout passes; with no gap the centres are 20px apart, the circles overlap and it fails. The 28px buttons in the solution below are already above 24px, so they pass without needing that exception.
- The 44px figure is the Level AAA criterion, stricter than the AA one. Treat AA as the minimum to pass and 44px as the working size for primary controls and anything dense.
Which media query: pointer or any-pointer
MDN defines the feature: "The pointer CSS media feature tests whether the user has a pointing device (such as a mouse), and if so, how accurate the primary pointing device is", where coarse means "a pointing device of limited accuracy, such as a finger on a touchscreen". MDN adds: "If you want to test the accuracy of any pointing device, use any-pointer instead." The primary pointing device is the one the system treats as the main input (on a phone, the touchscreen; on a laptop, usually the trackpad).
Decision: key the size change on (pointer: coarse). It changes density, which is a visible cost on a desktop screen, so it should fire when the primary input is a finger. What flips it: if the product has many touch laptops where users switch between mouse and finger, use (any-pointer: coarse) and accept the larger toolbar for mouse users too. A width query such as max-width: 600px is a poor proxy, because a small laptop window is not a touch device and a tablet is wider than 600px.
The solution
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toolbar</title>
<link rel="stylesheet" href="toolbar.css">
</head>
<body style="margin:0">
<div class="toolbar">
<button type="button" aria-label="Bold">B</button>
<button type="button" aria-label="Italic">I</button>
<button type="button" aria-label="Underline">U</button>
<button type="button" aria-label="Link">L</button>
<button type="button" aria-label="Quote">Q</button>
<button type="button" aria-label="Code">C</button>
<button type="button" aria-label="List">#</button>
<button type="button" aria-label="Clear">X</button>
</div>
<script>
window.clicks = [];
document.querySelectorAll('.toolbar button').forEach(b => b.addEventListener('click', () => window.clicks.push(b.getAttribute('aria-label'))));
</script>
</body>
</html>
.toolbar {
display: flex;
flex-wrap: wrap; /* lets the row break instead of shrinking the buttons when they grow */
gap: 4px;
width: 300px;
margin: 16px;
padding: 0;
}
.toolbar button {
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #667;
border-radius: 4px;
background: #eef0f6;
}
/* Primary input is a finger: same markup, bigger boxes. */
@media (pointer: coarse) {
.toolbar button {
width: 44px;
height: 44px;
}
}
- The desktop rules are untouched: 28px buttons with a 4px gap.
- The coarse block raises width and height to 44px. Because the buttons are real boxes of that size, the whole 44px square is the target and no extra hit-area trick is needed.
- The toolbar is 300px wide. At 44px with 4px gaps,
6 x 44 + 5 x 4 = 284pxfits and7 x 44 + 6 x 4 = 332pxdoes not, so the eight buttons break into rows of 6 and 2. On a fine pointer,8 x 28 + 7 x 4 = 252pxfits in one row. - Where wrapping would make the toolbar too tall (a 14-button toolbar at 44px wraps to three rows of 6, 6 and 2, as the last line of the output shows), the alternatives are horizontal scrolling or moving the least-used actions into an overflow menu. Those change the markup and behaviour, which is the point at which "no redesign" stops being possible.
Verifying it
The test runs in Chromium, Firefox and WebKit, the three browser engines (the rendering cores that lay out the page), driven by Playwright 1.48.2, a library that scripts real browsers; its WebKit build is Playwright's, not Safari, and it runs headless, meaning without a visible window, so it can measure layout and simulate a tap but cannot tell how a real thumb feels on a real phone. A fine pointer is the default context. A touch context is created with hasTouch: true, in which matchMedia('(pointer: coarse)') is true in all three engines. It asserts the sizes, the row counts, that nothing pokes out of the toolbar, and that a real touch tap 40px right and 20px down from the first button's corner (inside a 44px box, outside a 28px one) activates it. Deleting the coarse rule or the flex-wrap declaration makes the assertions fail; both mutants fail the 44px size assertion, the second because a non-wrapping flex row shrinks its items to fit instead of letting them overflow.
// check.mjs
import { chromium, firefox, webkit } from 'playwright';
import { readFileSync } from 'node:fs';
const css = readFileSync('toolbar.css', 'utf8');
const html = readFileSync('index.html', 'utf8');
const variants = {
shipped: css,
noCoarseRule: css.replace(/@media \(pointer: coarse\) \{[\s\S]*\n\}\n/, ''),
noWrap: css.replace('flex-wrap: wrap;', 'flex-wrap: nowrap;'),
};
const ok = (c, m) => { if (!c) throw new Error('ASSERT FAILED: ' + m); };
async function open(browser, variant, touch) {
const ctx = await browser.newContext({ viewport: { width: 400, height: 400 }, hasTouch: touch });
const p = await ctx.newPage();
await p.route('**/toolbar.css', r => r.fulfill({ contentType: 'text/css', body: variants[variant] }));
await p.route('**/index.html', r => r.fulfill({ contentType: 'text/html', body: html }));
await p.goto('http://x.test/index.html');
return p;
}
const layout = p => p.evaluate(() => {
const bar = document.querySelector('.toolbar');
const r = [...bar.querySelectorAll('button')].map(b => b.getBoundingClientRect());
const rows = {};
r.forEach(b => { rows[Math.round(b.top)] = (rows[Math.round(b.top)] || 0) + 1; });
return {
coarse: matchMedia('(pointer: coarse)').matches,
size: [r[0].width, r[0].height],
perRow: Object.values(rows),
overflowsBar: r.some(b => b.right > bar.getBoundingClientRect().right + 0.5),
minSide: Math.min(...r.map(b => Math.min(b.width, b.height))),
};
});
async function fineTest(b) {
const p = await open(b, 'shipped', false);
const m = await layout(p);
ok(!m.coarse && m.size.join() === '28,28' && m.perRow.join() === '8', 'fine pointer keeps the dense 28px single row');
return m;
}
async function coarseTest(b, variant) {
const p = await open(b, variant, true);
const m = await layout(p);
ok(m.coarse, 'touch context reports pointer: coarse');
ok(m.minSide >= 44, 'every button is at least 44 by 44');
ok(!m.overflowsBar, 'no button pokes out of the toolbar');
ok(m.perRow.join() === '6,2', 'wraps to rows of 6 and 2');
// a tap 40px right and 20px down from a button's top-left lands inside its 44px box but outside a 28px one
const first = await p.evaluate(() => { const r = document.querySelector('.toolbar button').getBoundingClientRect(); return { x: r.x, y: r.y }; });
await p.touchscreen.tap(first.x + 40, first.y + 20);
ok((await p.evaluate(() => window.clicks)).join() === 'Bold', 'tap near the edge of the 44px button activates it');
return m;
}
for (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {
const b = await type.launch();
const f = await fineTest(b);
const c = await coarseTest(b, 'shipped');
console.log(name, 'fine:', JSON.stringify({ size: f.size, perRow: f.perRow }), 'coarse:', JSON.stringify({ size: c.size, perRow: c.perRow }));
const res = {};
for (const v of ['noCoarseRule', 'noWrap']) { try { await coarseTest(b, v); res[v] = 'STILL PASSES'; } catch (e) { res[v] = 'fails: ' + e.message; } }
console.log(' without the rule:', JSON.stringify(res));
// 14 buttons: clone the first button until there are 14, then count buttons per row at 44px
const p14 = await open(b, 'shipped', true);
await p14.evaluate(() => { const bar = document.querySelector('.toolbar'); while (bar.children.length < 14) bar.append(bar.firstElementChild.cloneNode(true)); });
const m14 = await layout(p14);
ok(m14.perRow.join() === '6,6,2', '14 buttons wrap to rows of 6, 6 and 2');
console.log(' 14 buttons, coarse pointer, buttons per row:', JSON.stringify(m14.perRow));
await b.close();
}
Run in the Playwright 1.48.2 container, it prints the same values for all three engines (repeated runs print the same thing):
chromium fine: {"size":[28,28],"perRow":[8]} coarse: {"size":[44,44],"perRow":[6,2]}
without the rule: {"noCoarseRule":"fails: ASSERT FAILED: every button is at least 44 by 44","noWrap":"fails: ASSERT FAILED: every button is at least 44 by 44"}
14 buttons, coarse pointer, buttons per row: [6,6,2]
firefox fine: {"size":[28,28],"perRow":[8]} coarse: {"size":[44,44],"perRow":[6,2]}
without the rule: {"noCoarseRule":"fails: ASSERT FAILED: every button is at least 44 by 44","noWrap":"fails: ASSERT FAILED: every button is at least 44 by 44"}
14 buttons, coarse pointer, buttons per row: [6,6,2]
webkit fine: {"size":[28,28],"perRow":[8]} coarse: {"size":[44,44],"perRow":[6,2]}
without the rule: {"noCoarseRule":"fails: ASSERT FAILED: every button is at least 44 by 44","noWrap":"fails: ASSERT FAILED: every button is at least 44 by 44"}
14 buttons, coarse pointer, buttons per row: [6,6,2]
How to read it: size is the first button's width and height in CSS pixels; perRow lists how many buttons sit on each row, found by grouping the buttons by the rounded top coordinate of their boxes (getBoundingClientRect() returns a box's position and size), so [6,2] means a row of six and a row of two. In layout(), overflowsBar is true if any button's right edge passes the toolbar's right edge and minSide is the smallest side of any button. fineTest opens a normal (mouse-like) page and expects the dense single row; coarseTest opens a touch page and expects the 44px, two-row result plus the tap. without the rule reruns the touch checks against a stylesheet with the coarse block deleted or with flex-wrap switched off, and fails: followed by the assertion message means the checks caught the missing rule (both mutants trip the 44px size assertion first). The last line adds six more buttons (14 in all) and counts rows again.
Complexity and edge cases
The layout is a single flex pass over eight items. Edge cases to handle:
- Hybrid devices (laptops or tablets that have both a touchscreen and a mouse or trackpad):
pointerreports only the primary input (see the MDN definition above), so a touch laptop whose primary input is the trackpad keeps the dense toolbar;any-pointeris true if any attached input matches, so switch to it if that matters. - Labels: the buttons carry
aria-labelso the accessible name does not depend on the glyph; resizing them does not change it. - Page height: wrapping makes the toolbar taller on touch, which pushes content below it down; account for that in the surrounding layout.
- Zoom and user font size: the sizes are in CSS pixels, so they track browser zoom. If the buttons are sized in
reminstead, they also track the user's default font size.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 && npx playwright install chromium firefox webkit
# save the three listings above as index.html, toolbar.css and check.mjs, then:
node check.mjs
That is every published Frontend Fundamentals: HTML, CSS, and Responsive Styling question for UI Designer so far. Browse the other topics in this category, or practice this one interactively.