CSS Module 17 – Forms, tables, popovers and anchor positioning
Styling forms and form controls
Give form controls your font, then tint them with accent-color, rebuild them with appearance, show errors with :user-invalid and meet WCAG 2.2.
What you will learn
- Apply a form reset that gives every control the page's font and one box model in all engines
- Use accent-color, appearance and :user-invalid to restyle checkboxes, switches and error states
- Size text areas to their content with field-sizing, between a minimum and a maximum height
- Build a styleable select that falls back to a native one where the browser lacks support
- Check custom controls against the WCAG 2.2 target-size, contrast and focus-visibility requirements
Before you start
What you’ll learn · Before you start
What you will learn
- Apply a form reset that gives every control the page's font and one box model in all engines
- Use accent-color, appearance and :user-invalid to restyle checkboxes, switches and error states
- Size text areas to their content with field-sizing, between a minimum and a maximum height
- Build a styleable select that falls back to a native one where the browser lacks support
- Check custom controls against the WCAG 2.2 target-size, contrast and focus-visibility requirements
Before you start
On this page
Form controls are drawn partly by the browser engine and partly by the operating system, so CSS reaches them in
steps. The first step is font: inherit: each engine gives controls its own small font, and they never read the
page’s. After that you choose how much of the drawing to take over. accent-color tints the native checkbox,
radio button, slider and progress bar. appearance: none removes the native look of any control, and you draw
everything yourself. appearance: base-select gives a <select> a button and a picker you can style, while the
browser keeps its keyboard and screen reader behaviour. Three newer tools finish the job: :user-invalid shows
an error only after the person has interacted, field-sizing: content lets a text area grow with its text, and
:focus-visible draws the keyboard focus ring. Chromium 153, Firefox 155 and WebKit 26.6 produced every size quoted
here, and the WCAG 2.2 contrast formula every colour ratio.
How much of a control's look each technique gives you
Text description of the diagram
The diagram is a ladder of four steps. Each step down hands more of the control's drawing to you, and more to look after.
- font: inherit works on every control. The control takes your font and text colour; the engine still draws it.
- One step down, accent-color, for checkboxes, radio buttons, range sliders and progress bars. The engine still draws the control, in your colour.
- The next step, appearance: base-select, for select only. You style its button and its picker, and the browser keeps the keyboard and screen reader behaviour.
- The last step, appearance: none, for any control. You draw everything, including the checked, focus and disabled states and how the control looks in contrast themes.
Why controls ignore your font
Put a text input, a select, a button, a checkbox and a text area on a page set in 16px Arial, and each engine draws them in its own way:
- Chromium 153: 13.33px Arial; text input 153 × 21, button 38.98 × 21, checkbox 13 × 13.
- Firefox 155: a 13.33px system font; text input 189 × 22, button 34.25 × 22, checkbox 14 × 14.
- WebKit 26.6: 11px system-ui; text input 155.88 × 21, button 34.8 × 18, checkbox 12 × 12.
The sizes are CSS pixels, width × height. The controls ignore the page because the browser’s own stylesheet sets a font on them, and inheritance only fills in a property that no rule sets. A short reset fixes the font and gives every control the same box model, padding, border and minimum height:
/* A form reset: every control takes the page's font and one box model, so sizes match in every engine. */
:where(input, button, select, textarea) {
font: inherit; /* not the engine's own 11px to 13.33px default */
letter-spacing: inherit;
color: inherit;
box-sizing: border-box;
margin: 0;
}
/* Text fields and selects: one padding, border and minimum height. */
:where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"]), select, textarea) {
padding: 0.5rem 0.75rem;
border: 1px solid #5f6b7a; /* 5.43:1 against white, so the edge of the field stays visible */
border-radius: 0.375rem;
background-color: Field;
color: FieldText;
}
:where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"]), select, button) {
min-block-size: 2.75rem; /* 44px with a 16px root font size */
}
/* The select loses its native button, so padding and borders apply in every engine; the arrow is drawn back. */
:where(select) {
appearance: none;
padding-inline-end: 2.25rem;
background: Field url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%235f6b7a' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 0.75rem center / 0.75rem;
}
:where(button) {
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background-color: #1f4e8c;
color: #fff; /* 8.31:1 on the blue */
}
:where(input[type="checkbox"], input[type="radio"]) {
inline-size: 1.25rem; /* 20px: the label around the box makes the target big enough */
block-size: 1.25rem;
accent-color: #1f4e8c;
}
:where(textarea) {
resize: vertical;
}
:where(input, button, select, textarea):focus-visible {
outline: 3px solid #1f4e8c;
outline-offset: 2px;
} :where() keeps the reset’s base rules at zero specificity, so any class in your own styles wins over them. The
test page loads the reset switched off, measures the form, switches the reset on and measures again:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>One form before and after the form reset</title>
<!-- Loaded switched off; the script measures the form, switches the reset on and measures again. -->
<link rel="stylesheet" href="form-reset.css" media="not all" id="reset">
<style>
body { margin: 16px; font: 16px/1.5 Arial, sans-serif; }
form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
</style>
</head>
<body>
<form id="checkout">
<input type="text" aria-label="Name" value="Asha">
<select aria-label="State"><option>Kerala</option></select>
<button type="button">Pay</button>
<label><input type="checkbox" checked> Save</label>
<textarea aria-label="Note">Hi</textarea>
</form>
<script>
function sizes(when) {
const controls = document.querySelectorAll('#checkout :is(input, select, button, textarea)');
const row = [...controls].map((el) => {
const box = el.getBoundingClientRect();
const name = el.tagName === 'INPUT' ? el.type : el.tagName.toLowerCase();
return `${name} ${+box.width.toFixed(2)}x${+box.height.toFixed(2)} at ${getComputedStyle(el).fontSize}`;
});
console.log(`${when}: ${row.join(' | ')}`);
}
addEventListener('load', () => { // after load, the switched-off stylesheet has arrived too
sizes('defaults');
document.getElementById('reset').media = 'all';
sizes('with the reset');
});
</script>
</body>
</html> | Control, with the reset | Chromium, Firefox and WebKit |
|---|---|
| Text input | 44px tall; 192, 218 and 204px wide |
| Select | 44px tall; 97, 96.27 and 97px wide |
| Button “Pay” | 44px tall; 61.58, 61.57 and 61.58px wide |
| Checkbox | 20 × 20 in all three |
| Text area | 66px tall; 201, 218 and 166px wide |
Heights now agree to the pixel, and so do the button, the select and the checkbox. The text input and the text area
still differ in width, because without a width each engine turns the default size="20" and cols="20" into
pixels with its own idea of an average character. Give fields an inline-size, or let a grid or flex layout size
them, and the widths match too. The select needed appearance: none: with its native look, WebKit ignored the
padding, which computed to 0px and left the select 23px tall where the others grew to 37px. Without the native look
every engine applies the padding, and the reset draws the arrow back as a background image.
Tint a control with accent-color
accent-color sets the colour that the platform’s own drawing uses for the checked box, the dot of a radio button,
the filled part of a range slider and the bar of a progress element. The control keeps its native shape, size and
behaviour. The specification requires browsers to keep the control legible, and with a light yellow accent,
#ffd54f, they did it in two ways: Chromium and Firefox drew a dark check mark on the yellow, and WebKit darkened
the yellow and kept its white check mark. accent-color inherits, so one declaration on :root brands every control
on the page, and it doesn’t touch text inputs or the options of a native select.
accent-color works in Chrome, Edge, Firefox and Safari 26.2 and later, yet web-features rates it as limited
availability, because its data doesn’t list Chrome on Android. Where it isn’t applied, the control simply keeps its
default colour, so it is safe to use today.
Rebuild a control with appearance: none
appearance: none switches off the native drawing, and the control becomes a box you style like any other. A
checkbox with role="switch" becomes a switch this way. The knob is a ::before pseudo-element, which all three
engines drew on an appearance: none checkbox in our run:
/* A switch drawn on a real checkbox: <input type="checkbox" role="switch">, inside its <label>. */
.switch {
appearance: none; /* drop the native checkbox; everything below is drawn by you */
position: relative;
inline-size: 44px;
block-size: 24px;
margin: 0;
border: 2px solid #5f6b7a;
border-radius: 12px;
background: #fff;
}
.switch::before { /* the knob */
content: '';
position: absolute;
inset-block-start: 2px;
inset-inline-start: 2px;
inline-size: 16px;
block-size: 16px;
border-radius: 50%;
background: #5f6b7a;
}
.switch:checked { background: #1f4e8c; border-color: #1f4e8c; }
.switch:checked::before { translate: 20px 0; background: #fff; }
.switch:focus-visible { outline: 3px solid #1f4e8c; outline-offset: 2px; }
.switch:disabled { opacity: 0.6; }
@media (prefers-reduced-motion: no-preference) {
.switch::before { transition: translate 150ms; }
}
/* Contrast themes replace your colours: without this, the track and the knob become the same colour. */
@media (forced-colors: active) {
.switch::before { forced-color-adjust: none; background: CanvasText; }
.switch:checked { background: Highlight; border-color: Highlight; }
.switch:checked::before { background: HighlightText; }
} The input stays a real checkbox: it is still focusable, the Space key still toggles it, its label still clicks it,
and its value is still submitted with the form. What you take over is the look, including every state the browser
drew for you: checked, focus, disabled and contrast themes. That last state is the one that gets forgotten. With
forced colours emulated, Chromium and Firefox replaced the switch’s colours with system colours, and the track and
the knob came out the same light colour, so the switch looked off when it was on. The
@media (forced-colors: active) block paints the checked track with the Highlight system colour and the knob
with HighlightText. WebKit matched the emulated media query but replaced no colours.
Error styles that wait for the person
:invalid matches any field whose value breaks a constraint, from the moment the page loads. A required field that
is still empty is invalid, so styling :invalid greets people with red borders before they have typed anything.
:user-invalid matches the same fields, but only after the person has changed and left the field, or has tried to
submit the form:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error styles that wait for the user</title>
<style>
body { margin: 16px; font: 16px/1.5 Arial, sans-serif; }
input { font: inherit; border: 1px solid #5f6b7a; }
/* :invalid would paint both fields red as soon as the page loads; :user-invalid waits. */
input:user-invalid { border: 2px solid #b00020; } /* 7.33:1 against white */
.field:has(input:user-invalid) .hint { color: #b00020; font-weight: 700; }
</style>
</head>
<body>
<form id="pay" novalidate>
<p class="field">
<label for="email">Email</label>
<input id="email" type="email" required aria-describedby="email-hint">
<span class="hint" id="email-hint">We send the receipt here.</span>
</p>
<p class="field">
<label for="pin">PIN code</label>
<input id="pin" inputmode="numeric" pattern="[0-9]{6}" value="12" aria-describedby="pin-hint">
<span class="hint" id="pin-hint">Six digits, for example 682001.</span>
</p>
<button>Pay</button>
</form>
<script>
const form = document.getElementById('pay');
function report(when) {
for (const input of form.querySelectorAll('input')) {
console.log(`${when}, #${input.id}: :invalid ${input.matches(':invalid')}, :user-invalid ${input.matches(':user-invalid')}`);
}
}
report('on load');
form.addEventListener('submit', (event) => event.preventDefault()); // stay on the page for the demo
form.requestSubmit(); // a submission attempt, as when the person presses Pay
report('after requestSubmit()');
</script>
</body>
</html> - On load: both fields matched
:invalid, and neither matched:user-invalid. - After
requestSubmit(): both fields matched both selectors.
All three engines agreed. The HTML standard explains why: its form submission algorithm sets every field’s “user
validity” when a submission is attempted, even on a form with novalidate, and a committed change sets it for one
field. The script form.submit() skips that step, so it never turns error styles on. :user-invalid is Baseline
widely available.
Colour alone is not an error message. WCAG 2.2’s success criterion 3.3.1 asks for the error to be described in text,
and the example’s hint text sits in the page from the start, tied to its field with aria-describedby, so a screen
reader reads it with the field. The red border is 7.33:1 against white.
Text areas that grow with their text
A text area is sized by its rows attribute, two by default, and it scrolls once the text is longer.
field-sizing: content makes it size itself to its text instead, and min-block-size and max-block-size keep it
between two heights. The lh unit is one line height, so 3lh means three lines:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A text area that grows with its text</title>
<style>
body { margin: 16px; font: 16px/24px Arial, sans-serif; }
textarea {
font: inherit;
inline-size: 20em;
padding: 4px;
border: 1px solid #5f6b7a;
box-sizing: border-box;
}
.grow {
field-sizing: content; /* size to the text instead of the rows attribute */
min-block-size: 3lh; /* never shorter than three lines */
max-block-size: 8lh; /* then scroll */
}
</style>
</head>
<body>
<textarea id="fixed" aria-label="A fixed text area">One line</textarea>
<textarea id="one" class="grow" aria-label="One line">One line</textarea>
<textarea id="five" class="grow" aria-label="Five lines">1
2
3
4
5</textarea>
<textarea id="twelve" class="grow" aria-label="Twelve lines">1
2
3
4
5
6
7
8
9
10
11
12</textarea>
<script>
for (const area of document.querySelectorAll('textarea')) {
const scrolls = area.scrollHeight > area.clientHeight;
console.log(`#${area.id}: ${area.getBoundingClientRect().height}px tall, field-sizing ${getComputedStyle(area).fieldSizing}${scrolls ? ', scrolls' : ''}`);
}
</script>
</body>
</html> | Text area | Height in all three engines |
|---|---|
| fixed, the default two rows | 58px |
one line, field-sizing: content |
72px, held at the three-line minimum |
| five lines | 130px |
| twelve lines | 192px, held at the eight-line maximum, and it scrolls |
Five lines are 5 × 24 = 120px of text, plus 8px of padding and 2px of border. field-sizing is Baseline newly
available, so it now works in all three engines; an older browser ignores it and keeps the rows height, which is
still usable. It also works on text inputs, which then grow sideways with their value.
A select you can style: appearance: base-select
A native <select> opens a list drawn by the operating system, and CSS can’t style that list. With
appearance: base-select on the select and on its ::picker(select) pseudo-element, the browser draws the button and
the list in the page instead, with plain default styles you can change. <selectedcontent> inside the select’s first
<button> shows a copy of the chosen option, ::picker-icon is the arrow, ::checkmark is the tick beside the
chosen option, and :open matches while the list is open. The keyboard behaviour, form value and accessibility
roles of a select are kept. A language picker:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A language picker that styles where it can</title>
<style>
body { margin: 16px; font: 16px/1.5 Arial, sans-serif; }
select { font: inherit; }
/* Only engines that support the styleable select read this block; the others keep their native select. */
@supports (appearance: base-select) {
select, ::picker(select) { appearance: base-select; }
select {
min-inline-size: 12rem;
padding: 0.5rem 0.75rem;
border: 2px solid #1f4e8c;
border-radius: 0.5rem;
}
::picker(select) { border: 1px solid #1f4e8c; border-radius: 0.5rem; padding: 0.25rem; }
option { padding: 0.5rem 0.75rem; }
option:checked { font-weight: 700; }
select:open::picker-icon { rotate: 180deg; }
}
</style>
</head>
<body>
<label for="lang">Language</label>
<select id="lang">
<button><selectedcontent></selectedcontent></button>
<option value="en" selected>English</option>
<option value="hi" lang="hi">हिन्दी</option>
<option value="ta" lang="ta">தமிழ்</option>
<option value="bn" lang="bn">বাংলা</option>
<option value="te" lang="te">తెలుగు</option>
</select>
<script>
const select = document.getElementById('lang');
const box = select.getBoundingClientRect();
console.log(`base-select supported: ${CSS.supports('appearance', 'base-select')}; appearance ${getComputedStyle(select).appearance}; select ${box.width}x${box.height}px`);
</script>
</body>
</html> In Chromium and WebKit the select measured 192 × 44, and a click opened a styled list right under the button, with
a tick beside English and each language name in its own script. Firefox ignored the button and the
@supports block and showed its native select, 82.5 × 25.5, which still works. That is the right fallback, and the
reason the whole styled block sits inside @supports (appearance: base-select).
The styleable select is limited availability: Chrome and Edge 135 and later and Safari 27 support it, Firefox
doesn’t yet, and the draft specification calls the value plain base. Give each option in another language a
lang attribute, as the example does: WCAG 2.2’s success criterion 3.1.2 asks for the language of such parts to be
marked, so that a screen reader reads हिन्दी and தமிழ் with the right voice.
Targets and focus rings that meet WCAG 2.2
WCAG 2.2’s success criterion 2.5.8, Target Size (Minimum), asks for pointer targets of at least 24 by 24 CSS
pixels. A smaller target still passes when a circle 24px across, centred on it, touches no other target and no other
small target’s circle, so 20px icons 4px apart pass and 3px apart fail. Links inside a sentence are exempt, and so
are controls whose size is set by the browser and not changed by you. The reset changes the checkbox to 20px, so
that exception no longer covers it. A target, though, is the whole region that accepts the click, and a click
anywhere on the label toggles the checkbox, so the label’s box is the target. Make the label a flex row at least
24px tall (display: inline-flex; align-items: center; min-block-size: 24px) and the checkbox passes.
A focus ring needs a contrast of at least 3:1 against the colours next to it (success criteria 1.4.11 and 2.4.7), and so does the border that shows where an empty text field is. Text needs 4.5:1, placeholder text included. The program checks the reset’s colours with the WCAG formula:
// The contrast checks behind the form reset, with the WCAG 2.2 formula: text needs 4.5:1 against its background
// (1.4.3), and the edges, states and focus rings of controls need 3:1 against the colours next to them (1.4.11).
// Change the colours and run it again.
function luminance(hex) {
const value = parseInt(hex.slice(1), 16);
const channels = [value >> 16, (value >> 8) & 255, value & 255].map((c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
const checks = [
// what, foreground, background, the minimum it needs
['field border on a white page', '#5f6b7a', '#ffffff', 3],
['focus ring on a white page', '#1f4e8c', '#ffffff', 3],
['button text on the blue', '#ffffff', '#1f4e8c', 4.5],
['error border and text on white', '#b00020', '#ffffff', 4.5],
['placeholder grey #9aa0a6 on white', '#9aa0a6', '#ffffff', 4.5],
['the same focus ring on a #121212 dark page', '#1f4e8c', '#121212', 3],
['a lighter ring for the dark page', '#8ab4f8', '#121212', 3],
];
for (const [what, fg, bg, min] of checks) {
const ratio = contrast(fg, bg);
console.log(`${what.padEnd(44)} ${ratio.toFixed(2).padStart(5)}:1 needs ${min}:1 ${ratio >= min ? 'pass' : 'FAIL'}`);
} Output
field border on a white page 5.43:1 needs 3:1 pass focus ring on a white page 8.31:1 needs 3:1 pass button text on the blue 8.31:1 needs 4.5:1 pass error border and text on white 7.33:1 needs 4.5:1 pass placeholder grey #9aa0a6 on white 2.64:1 needs 4.5:1 FAIL the same focus ring on a #121212 dark page 2.25:1 needs 3:1 FAIL a lighter ring for the dark page 8.89:1 needs 3:1 pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node contrast_checks.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The blue ring passes on white with 8.31:1 and fails on a dark page with 2.25:1, so a dark theme needs its own ring
colour, and the light grey often used for placeholders fails at 2.64:1. A placeholder also disappears as soon as the
person types, so it never replaces a <label>.
The button of a file input is a separate part, ::file-selector-button, which is Baseline widely available, so you
can give it the same look as your other buttons without hiding the input.
Practise: target sizes
Write the target-size check of success criterion 2.5.8, spacing exception included:
Exercise · Medium · JavaScript
Check a row of controls against WCAG 2.2 target sizes
A checkout form has small icon buttons, checkboxes and links close together. WCAG 2.2 success criterion 2.5.8 asks for pointer targets of at least 24 by 24 CSS pixels, with a spacing exception for smaller ones. Write targetSizeFailures(targets) in targets.mjs and export it.
Each target is { id, x, y, width, height } in CSS pixels, the box that accepts the click (for a checkbox inside a <label>, the label's box), and may have inline: true for a link inside a sentence. Return the ids of the targets that fail, in the order they were given:
- A target at least 24 px wide and 24 px tall passes.
- A link with
inline: truepasses: the inline exception covers it. It is still a target that others must keep clear of. - Any other target is undersized. Draw a circle 24 px across (radius 12) centred on its box. It passes when that circle intersects no other target's box and no other undersized target's circle. Touching is not intersecting: a circle exactly 12 px from a box, or two centres exactly 24 px apart, still pass.
The distance from a point to a box is 0 inside the box; otherwise it is the distance to the box's nearest edge or corner. The sample tests import the function from targets.mjs and run in your browser.
Starter code · targets.mjs
/** The ids of the targets that fail WCAG 2.2 success criterion 2.5.8 (Target Size, Minimum), in input order. */
export function targetSizeFailures(targets) {
// Replace this line with your code.
return [];
} The sample tests · targets.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { targetSizeFailures } from './targets.mjs';
const box = (id, x, y, width, height, extra = {}) => ({ id, x, y, width, height, ...extra });
test('targets of at least 24 by 24 pass, even when they touch or overlap', () => {
assert.deepEqual(targetSizeFailures([box('pay', 0, 0, 24, 24), box('save', 24, 0, 24, 24), box('help', 44, 0, 24, 24)]), []);
assert.deepEqual(targetSizeFailures([box('a', 0, 0, 30, 24), box('b', 0, 20, 30, 24)]), []);
});
test('a target that is narrow or short is undersized', () => {
assert.deepEqual(targetSizeFailures([box('tall', 0, 0, 20, 40), box('card', 20, 0, 40, 40)]), ['tall']);
assert.deepEqual(targetSizeFailures([box('flat', 0, 0, 40, 20), box('card', 0, 20, 40, 40)]), ['flat']);
});
test('20 px icons 4 px apart pass; 3 px apart they fail', () => {
assert.deepEqual(targetSizeFailures([box('bold', 0, 0, 20, 20), box('italic', 24, 0, 20, 20), box('link', 48, 0, 20, 20)]), []);
assert.deepEqual(targetSizeFailures([box('bold', 0, 0, 20, 20), box('italic', 23, 0, 20, 20)]), ['bold', 'italic']);
});
test('a circle that only touches another box passes', () => {
assert.deepEqual(targetSizeFailures([box('icon', 0, 0, 16, 16), box('button', 20, 0, 80, 40)]), []);
assert.deepEqual(targetSizeFailures([box('icon', 0, 0, 16, 16), box('button', 19, 0, 80, 40)]), ['icon']);
});
test('neighbours on every side count', () => {
const icon = box('icon', 100, 100, 16, 16); // centre 108,108
assert.deepEqual(targetSizeFailures([icon, box('left', 57, 90, 40, 40)]), ['icon']);
assert.deepEqual(targetSizeFailures([icon, box('right', 119, 90, 40, 40)]), ['icon']);
assert.deepEqual(targetSizeFailures([icon, box('above', 90, 57, 40, 40)]), ['icon']);
assert.deepEqual(targetSizeFailures([icon, box('below', 90, 119, 40, 40)]), ['icon']);
assert.deepEqual(targetSizeFailures([icon, box('far', 121, 90, 40, 40), box('farther', 90, 121, 40, 40)]), []);
});
test('inline links are exempt, but still targets that others must clear', () => {
const link = box('terms', 0, 0, 60, 18, { inline: true });
assert.deepEqual(targetSizeFailures([link, box('icon', 64, 0, 16, 16)]), []);
assert.deepEqual(targetSizeFailures([link, box('icon', 62, 0, 16, 16)]), ['icon']);
assert.deepEqual(targetSizeFailures([box('icon', 0, 0, 16, 16), box('ref', 20, 4, 8, 8, { inline: true })]), []);
}); A hint
Write two small helpers first: the centre of a box, [x + width / 2, y + height / 2], and the distance from a point to a box, with dx = Math.max(box.x - px, 0, px - (box.x + box.width)), the same for dy, and Math.hypot(dx, dy). An undersized target then fails when some other target is closer than 12 px to its centre, or when another undersized target's centre is closer than 24 px.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between :invalid and :user-invalid, and which should style an error message?
Both match a form field whose value breaks one of its constraints, such as required, pattern, type="email" or
max. :invalid matches from the moment the page loads, so an empty required field is already invalid before the
person has typed anything, and styling it paints errors on a fresh form. :user-invalid matches the same fields
only after the person has interacted with them: after they change a field’s value and move on, or after they try to
submit the form. Use :user-invalid for red borders and error text; it is Baseline widely available. In our run
both selectors matched an empty required field and a short PIN code, but :user-invalid only after requestSubmit().
Colour is never the only signal: describe the error in text and tie the text to the field with aria-describedby.
Key takeaways
- Controls use the engine’s own font (13.33px Arial, a 13.33px system font or 11px system-ui), so start with
font: inherit,box-sizing: border-boxand one padding and border. accent-colortints checkboxes, radio buttons, sliders and progress bars and keeps their native behaviour.appearance: nonehands you the whole drawing: checked, focus, disabled and forced colours too.:user-invalidwaits for the person;:invalidmatches on load. Describe every error in text.field-sizing: contentwithmin-block-sizeandmax-block-sizemakes a text area grow, then scroll.appearance: base-selectstyles a select in Chromium and Safari 27; wrap it in@supportsand Firefox keeps the native select.- Targets need 24 by 24 CSS pixels or the spacing circle; focus rings and control edges need 3:1, text 4.5:1.
References
- CSS Basic User Interface Module Level 4: accent-color (W3C CSS Working Group)
- CSS Basic User Interface Module Level 4: appearance (W3C CSS Working Group)
- CSS Form Control Styling Module Level 1: field-sizing (W3C CSS Working Group)
- CSS Form Control Styling Module Level 1: the ::picker() pseudo-element (W3C CSS Working Group)
- Selectors Level 4: :user-valid and :user-invalid (W3C CSS Working Group)
- HTML Standard: user validity and the form submission algorithm (WHATWG)
- HTML Standard: the select element (WHATWG)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Understanding WCAG 2.2: Target Size (Minimum) (W3C)
- Understanding WCAG 2.2: Non-text Contrast (W3C)
- Understanding WCAG 2.2: Error Identification (W3C)
- Media Queries Level 5: forced-colors (W3C CSS Working Group)
- Customizable select elements (MDN Web Docs) (Mozilla)
- field-sizing (MDN Web Docs) (Mozilla)
- ::file-selector-button (MDN Web Docs) (Mozilla)
- Web features explorer: accent-color (W3C WebDX Community Group)
- Web features explorer: field-sizing (W3C WebDX Community Group)
- Web features explorer: customizable select (W3C WebDX Community Group)
- Web features explorer: :user-valid and :user-invalid (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress