Form Controls and Interaction Properties: accent-color, appearance, caret-color, field-sizing, cursor, pointer-events and user-select
Tie form controls to the page with font: inherit and color-scheme first and color them with accent-color; in a custom control built with appearance: none, show the state with a shape, not a color. The post measures Bookstore's checkout form (stepper, gift wrap checkbox, a note that grows with field-sizing) and pointer-events, user-select, cursor and interactivity in Chromium and WebKit.
Version note: This post follows the CSS Basic User Interface Level 4, CSS Color Adjustment Level 1, CSS Box Sizing Level 4 (field-sizing) and CSS Form Control Styling Level 1 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.
Guess First
For Bookstore, the online bookstore used as the shared example across this site's series, we finish the checkout form: quantity buttons, a gift wrap checkbox, an order note that grows, and a discount badge on the book page that does not block clicks on the cover. First, a button:
/* 6) pointer-events: none stops the mouse, not the keyboard */
#ghost { pointer-events: none; }
<button type="button" id="ghost">Pay now</button>
Clicking it with the mouse does nothing; if it were also shown in gray, users would take it for disabled. But is the button really disabled? Can someone who reaches it with the keyboard and presses Enter make a payment?
The Form Reset: font: inherit and color-scheme
Form controls do not take the page font by default; the browser's own style sheet gives them a separate one. The lab has two inputs, the second with font: inherit:
/* 1) form controls have their own font until you reset it */
#reset { font: inherit; }
/* 2) native controls follow color-scheme */
.dark { color-scheme: dark; }
node scripts/12-forms.mjs
--- chromium
font-size: default input 13.3333px, font: inherit 16px
color-scheme: dark input rgb(59, 59, 59) + rgb(133, 133, 133)
--- webkit
font-size: default input 11px, font: inherit 16px
color-scheme: dark input rgb(30, 30, 30) + rgb(255, 255, 255)
The unstyled input is 13.33 pixels in Chromium and 11 in WebKit; with font: inherit both go up to the page's 16 pixels. In Bookstore we used to write this in each component; now base.css does it once for every control:
/* form controls do not take the page font by default (13.33px in Chromium, 11px in WebKit) */
input, button, select, textarea { font: inherit; }
The 16 pixels have one more benefit: iOS Safari zooms the page when an input with a font size below 16 pixels gets focus. While the user fills in the form, the page grows and scrolls sideways. I did not test this on a real iPhone, but the 16 pixel rule is the known fix for this behavior and font: inherit gives it to you for free. Turning zoom off with maximum-scale=1 hurts accessibility.
The second reset topic is dark mode. color-scheme: dark tells the browser to draw the native controls in that area, such as inputs, checkboxes and scrollbars, in dark colors. In the measurement the background of the dark input is rgb(59, 59, 59) in Chromium and rgb(30, 30, 30) in WebKit. Without it, a dark page keeps white inputs. Bookstore's :root has color-scheme: light for now; theming and light-dark() are in post 10 of the site's CSS series (CSS3: Flexbox, Grid and Modern CSS).
accent-color
One property changes the color of checkboxes, radios, ranges and progress bars: accent-color:
/* 3) accent-color: the browser keeps the check mark readable */
.accent input { inline-size: 40px; block-size: 40px; margin: 4px; accent-color: #b91c1c; }
.accent #light-check { accent-color: #fde68a; }
--- chromium
accent-check rgb(185, 28, 28) + rgb(255, 255, 255)
accent-radio rgb(255, 255, 255) + rgb(185, 28, 28)
light-check rgb(253, 230, 138) + rgb(59, 59, 59)
--- webkit
accent-check rgb(185, 28, 28) + rgb(255, 255, 255)
accent-radio rgb(185, 28, 28) + rgb(255, 255, 255)
light-check rgb(206, 187, 111) + rgb(255, 255, 255)
Both engines use the red accent as it is: the box is filled with rgb(185, 28, 28) and the check mark is white. The light yellow, #fde68a, is the interesting one. A white mark is unreadable on light yellow; Chromium keeps the yellow fill and turns the mark dark gray, rgb(59, 59, 59), while WebKit keeps the mark white and darkens the fill to rgb(206, 187, 111). Different paths, same goal: enough contrast between the mark and the fill. So the color you give is not always drawn exactly.
web-features counts accent-color as not Baseline, and the doubt in the plan turned out to be right: the reason is Chrome for Android. According to the browser-compat-data note, Chrome on Android, like Safari before 26.2, does not keep this minimum contrast for the control's legibility. The color itself is applied everywhere; only when you pick a light accent can the white mark become unreadable on Android. With a dark, saturated color the difference disappears. In Bookstore the checkout form gets the link color:
/* native radios and checkboxes in the link color; the browser keeps the mark readable */
.checkout-form { accent-color: var(--color-link); }
.shipping-option { cursor: pointer; }
/* custom checkbox: appearance: none removes the native look, not the behavior */
.check {
appearance: none;
display: inline-grid;
place-content: center;
flex: none;
inline-size: 1.5rem;
block-size: 1.5rem;
margin: 0;
border: 2px solid var(--color-muted);
border-radius: 0.25rem;
background: var(--color-surface);
}
.check:checked { border-color: var(--color-link); background: var(--color-link); }
/* the mark is a border, so forced colors keep it; it exists only when checked */
.check::before {
content: "";
display: none;
inline-size: 0.75rem;
block-size: 0.375rem;
border: solid #ffffff;
border-width: 0 0 3px 3px;
rotate: -45deg;
translate: 0 -2px;
}
.check:checked::before { display: block; }
.gift { display: flex; gap: 0.75rem; align-items: center; }
/* quantity stepper */
.order-items { padding: 0; border: 0; }
.order-item { display: flex; gap: 1rem; align-items: center; }
.order-item .cost { margin-inline-start: auto; font-weight: 700; }
.stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: 999px;
}
.stepper button {
inline-size: 2.5rem;
block-size: 2.5rem;
border: 0;
background: none;
cursor: pointer;
/* no double-tap zoom, so no tap delay */
touch-action: manipulation;
/* fast taps do not select the text around; Safari before 27.3 needs the prefix */
-webkit-user-select: none;
user-select: none;
}
.stepper input {
inline-size: 3rem;
padding: 0;
border: 0;
text-align: center;
}
/* order note: grows with its text; browsers without field-sizing keep rows and resize */
.field textarea {
inline-size: 100%;
min-block-size: 5rem;
max-block-size: 15rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
field-sizing: content;
resize: vertical;
}
.field input, .field textarea { caret-color: var(--color-accent); }
A Custom Checkbox with appearance: none
Where accent-color is not enough, for size, corners or the shape of the mark, appearance: none removes the control's native look and lets you style it like an ordinary box. It removes only the look: the input is still an input, it gets keyboard focus, Space toggles it and the form submits it. The lab has two approaches:
/* 4) appearance: none — a check mark drawn with a border */
.check {
appearance: none;
display: inline-grid;
place-content: center;
inline-size: 24px;
block-size: 24px;
margin: 4px;
border: 2px solid #4b5563;
border-radius: 4px;
}
.check:checked { border-color: #1d4ed8; background: #1d4ed8; }
.check::before {
content: "";
inline-size: 12px;
block-size: 6px;
border: solid transparent;
border-width: 0 0 3px 3px;
rotate: -45deg;
translate: 0 -2px;
}
/* naive: a transparent mark, made white when checked */
.naive:checked::before { border-color: #ffffff; }
/* better: no mark at all until the box is checked */
.better::before { display: none; }
.better:checked::before { display: block; border-color: #ffffff; }
--- chromium
check naive-on rgb(29, 78, 216) + rgb(255, 255, 255)
check naive-off rgb(255, 255, 255) + rgb(75, 85, 99)
check better-on rgb(29, 78, 216) + rgb(255, 255, 255)
check better-off rgb(255, 255, 255) + rgb(75, 85, 99)
Space on #better-off: checked true
--- webkit
check naive-on rgb(29, 78, 216) + rgb(255, 255, 255)
check naive-off rgb(255, 255, 255) + rgb(75, 85, 99)
check better-on rgb(29, 78, 216) + rgb(255, 255, 255)
check better-off rgb(255, 255, 255) + rgb(75, 85, 99)
Space on #better-off: checked true
--- chromium, forced colors
check naive-on dark pixels 214, background rgb(255, 255, 255)
check naive-off dark pixels 214, background rgba(255, 255, 255, 0)
check better-on dark pixels 214, background rgb(255, 255, 255)
check better-off dark pixels 156, background rgba(255, 255, 255, 0)
In normal mode the pixels of both approaches are the same: the checked box is blue, the unchecked box white with a gray border. Space checks the box in both engines. The difference shows in forced colors mode, Windows' high contrast themes, which only Chromium can emulate. Forced colors reduce colors to a few system colors: backgrounds become Canvas, white here, and borders CanvasText, black. With the naive approach the transparent mark of the unchecked box turned black too and draws as many dark pixels as the checked box, 214: users cannot tell the two apart. The blue fill of the checked box is gone as well, so the mark is the only thing that shows the state. With the better approach the mark exists only while :checked; the unchecked box keeps only its border, 156 pixels.
The lesson: when you show a state with appearance: none, show it with a shape that is there or not there, not with a color. Bookstore's "Gift wrap" box uses the better approach. The measurement on the checkout page:
<fieldset class="order-items">
<legend>Your order</legend>
<div class="order-item">
<span>Middlemarch</span>
<div class="stepper">
<button type="button" data-step="-1" aria-label="One less">−</button>
<input id="quantity" name="quantity" value="1" inputmode="numeric" data-min="1" data-max="10" aria-label="Quantity">
<button type="button" data-step="1" aria-label="One more">+</button>
</div>
<span class="cost">₺385.50</span>
</div>
</fieldset>
<label class="gift"><input type="checkbox" class="check" name="gift" value="yes"> Gift wrap (₺25.00)</label>
<div class="field">
<label for="note">Order note</label>
<textarea id="note" name="note" rows="3" placeholder="Anything we should know?"></textarea>
</div>
node scripts/12-pages.mjs
--- chromium
Tab from the shipping radios → .check; on .check: outline solid 3px
Space → checked true, ::before display block
--- webkit
Tab from the shipping radios → #note; on .check: outline solid 3px
Space → checked true, ::before display block
In Chromium, Tab went straight from the radio group to the checkbox. In WebKit focus jumped to the order note: on macOS, Tab stops only at text fields by default, and a Safari setting changes that; the measurement focuses the checkbox directly there. In both engines the box gets the 3 pixel ring from :focus-visible: appearance: none does not remove the focus ring, but outline: none would, as this series' border and outline post shows. I ran the forced colors pixel check in the lab: on the full checkout page Chromium's emulation showed the same bug described in the series' clip-path and mask post, a computed black border painted gray.
A native control has a few more parts. caret-color changes the color of the text cursor; Bookstore uses the accent color in inputs and the textarea, rgb(185, 28, 28) in both engines. ::placeholder and ::file-selector-button are in this series' pseudo-element post. There is no standard pseudo-element for the thumb of input[type=range], the round part you drag; every engine has its own prefixed name:
/* 5) range: the thumb needs a prefixed pseudo-element per engine */
#range { appearance: none; inline-size: 200px; block-size: 6px; background: #d1d5db; border-radius: 3px; }
#range::-webkit-slider-thumb { appearance: none; inline-size: 20px; block-size: 20px; border-radius: 50%; background: #b91c1c; }
#range::-moz-range-thumb { inline-size: 20px; block-size: 20px; border: 0; border-radius: 50%; background: #b91c1c; }
--- chromium
range thumb: rgb(209, 213, 219) + rgb(185, 28, 28)
--- webkit
range thumb: rgb(209, 213, 219) + rgb(185, 28, 28)
The track is gray, the thumb red; ::-webkit-slider-thumb works in Chromium and WebKit. In Firefox the counterpart is ::-moz-range-thumb; I could not run Firefox on this machine, so that rule is untested. Do not combine the two in one selector list: a browser that sees a pseudo-element it does not know drops the whole rule, so each engine gets its own rule.
A Growing Textarea with field-sizing
A textarea with a fixed height for the order note either leaves empty space for a short note or shows a scrollbar for a long one. field-sizing: content makes the browser size the control by its content; min-block-size and max-block-size set the limits:
--- chromium
order note: 0 lines 80px, 6 lines 172px, 20 lines 240px (scrolls)
--- webkit
order note: 0 lines 80px, 6 lines 172px, 20 lines 240px (scrolls)
The empty note is 80 pixels, the 5rem minimum, grows to 172 pixels with six lines, and stops at 240 pixels, the 15rem maximum, with twenty lines, where the content starts to scroll. The result is the same in both engines. field-sizing has been newly available since June 2026, with Firefox 152; a browser without it uses the height from rows="3" and the handle from resize: vertical. The JavaScript once written for this, reading scrollHeight on every input and setting the height, is no longer needed.
To style the select itself there is appearance: base-select, the customizable select; both engines recognize the value in the measurement. According to web-features it is in Chrome 135 and Safari 27 but not in Firefox, so it is not Baseline. The element side and the progressive enhancement approach are in post 07 of the site's HTML series (HTML5 and Semantic HTML); here we only note that a browser that does not know base-select shows the native select, so it is safe to add.
pointer-events: the Link Behind the Badge
The answer to the opening question:
--- chromium
pointer-events: none button: clicks by mouse 0, after focus + Enter 1
--- webkit
pointer-events: none button: clicks by mouse 0, after focus + Enter 1
The mouse click produced 0 clicks, focus and Enter produced 1: pointer-events: none only stops the mouse and touch from finding a target. The button stays in the Tab order, works with Enter and Space, and screen readers read it as a normal button. To disable a control use the disabled attribute; to make a whole area inactive use HTML's inert attribute.
The right use of pointer-events: none is the opposite: a decoration that sits on top but should not be clicked passes the click to what is underneath. On the "Recommended" strip of the book page we put a discount badge over the Persuasion cover:
<li><a href="book.html"><img src="img/cover-persuasion.webp" alt="Persuasion" width="240" height="360" loading="lazy"></a><span class="sale-badge">15% off</span></li>
/* the badge sits on the cover, but clicks go through it to the link */
.sale-badge {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--color-accent);
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
pointer-events: none;
}
--- chromium
sale badge center hits: pointer-events none → IMG inside A, auto → SPAN
--- webkit
sale badge center hits: pointer-events none → IMG inside A, auto → SPAN
With pointer-events: none the point in the middle of the badge lands on the cover image, inside the link; with auto it lands on the badge's span. Without it, some clicks would go nowhere. The badge text is outside the link, so a screen reader reads "15% off" after the "Persuasion" link.
user-select, cursor and interactivity
The quantity buttons use two more properties. One is touch-action: manipulation from the previous post, which turns off double-tap zoom and removes the tap delay; the other is user-select: none. The lab has the same line three times:
/* 7) user-select */
#unprefixed button { user-select: none; }
#both button { -webkit-user-select: none; user-select: none; }
#code { -webkit-user-select: all; user-select: all; }
<p id="plain">Quantity <button type="button">−</button> 2 <button type="button">+</button></p>
<p id="unprefixed">Quantity <button type="button">−</button> 2 <button type="button">+</button></p>
<p id="both">Quantity <button type="button">−</button> 2 <button type="button">+</button></p>
--- chromium
select #plain "Quantity − 2 +"
select #unprefixed "Quantity 2 "
select #both "Quantity 2 "
one click on #code selects "BK-1042"
#unprefixed button: user-select none, -webkit-user-select none
--- webkit
select #plain "Quantity − 2 +"
select #unprefixed "Quantity − 2 +"
select #both "Quantity 2 "
one click on #code selects "BK-1042"
#unprefixed button: user-select none, -webkit-user-select text
Selecting the whole plain line copies "Quantity − 2 +". The line with only user-select: none leaves the buttons out in Chromium but not in WebKit. The last line shows why: WebKit 27.2 parses the unprefixed user-select, its computed value is none and CSS.supports() returns true, but it does not apply it; the behavior comes from -webkit-user-select, which is still text. According to browser-compat-data the unprefixed user-select arrives with Safari 27.3; Playwright's WebKit build is not the released Safari, but this result agrees with that. This is why user-select is not Baseline: for Safari, write both lines. user-select: all selects a code or an order number with a single click: BK-1042 in both engines. Bookstore's stepper:
// Quantity buttons: change the number in the field, between data-min and data-max.
for (const stepper of document.querySelectorAll('.stepper')) {
const input = stepper.querySelector('input');
for (const button of stepper.querySelectorAll('button')) {
button.addEventListener('click', () => {
const value = Number(input.value) + Number(button.dataset.step);
input.value = String(Math.min(Number(input.dataset.max), Math.max(Number(input.dataset.min), value)));
});
}
}
--- chromium
stepper: 3 × + → 4, then 5 × − → 1; touch-action manipulation
selecting the order row copies "Middlemarch ₺385.50"
--- webkit
stepper: 3 × + → 4, then 5 × − → 1; touch-action manipulation
selecting the order row copies "Middlemarch ₺385.50"
Three clicks on + give 4, five clicks on − stop at the lower limit of 1. A user who selects and copies the row gets "Middlemarch ₺385.50" without the button symbols mixed in. The quantity field is not type="number" but a text field with inputmode="numeric": the number input's own small spin buttons would clash with the stepper, and hiding them would again need prefixed pseudo-elements.
The last two lines of the lab are for cursor and interactivity:
/* 8) a custom cursor always ends with a keyword fallback */
#zoom { cursor: url("../img/heart.svg") 8 8, zoom-in; }
/* 9) interactivity: inert (Chromium only) */
#inert-box { interactivity: inert; }
--- chromium
interactivity: inert supported true, focus() on the button inside → active element BODY
cursor: url("http://bookstore.test/img/heart.svg") 8 8, zoom-in
appearance: base-select supported true
--- webkit
interactivity: inert supported false, focus() on the button inside → active element inert-button
cursor: url("http://bookstore.test/img/heart.svg") 8 8, zoom-in
appearance: base-select supported true
Always end a custom cursor image with a keyword; it is used when the image does not load or the browser cannot use it, and Firefox has rejected custom cursors larger than 32×32 pixels since version 67. 8 8 is the position of the click point inside the image. cursor: pointer is a common choice on clickable elements such as buttons, links and labels; in Bookstore we gave it to the shipping option cards, because the whole card is a label. web-features counts cursor as not Baseline because of limited support on iPad; on desktop every value works.
interactivity: inert is the CSS counterpart of HTML's inert attribute: in Chromium the button could not get focus() and the active element stayed BODY; WebKit does not know the property and the button got focus. Because it exists only in Chromium, the inert attribute, widely available since October 2025, is what you use today for jobs such as shutting off the background while a modal is open. -webkit-tap-highlight-color, which removes the gray box that appears over tapped links and buttons on mobile, is not standard; if you remove it, give your own :active feedback.
In Production
Check form controls in four states: normal, dark mode with color-scheme, forced colors, and 200% zoom. Custom controls usually look fine in the first two and break in the last two. Forced colors are turned on with the contrast themes in Windows Settings; you can emulate them in Chromium with Playwright, but do pixel checks on small pages.
When choosing between accent-color and appearance: none, try accent-color first: the native control already behaves correctly with the keyboard, screen readers, forced colors and touch target size. Every gap in a custom control, such as the focus ring, forced colors, the :disabled look or the error state, is your responsibility. The selectors for validation states such as :user-invalid are in post 05 of the HTML5 series, the contrast rules in the Accessibility (a11y) series.
Giving the pay button pointer-events: none to stop a double payment is a common mistake. Make the button disabled while the request is running, or block the second submit in the form's submit handler; on the server, the same order must not be processed twice either.
Common Pitfalls
First, treating a control with pointer-events: none as disabled. The keyboard and assistive technologies keep using it; use disabled or inert.
Second, showing the state of an appearance: none control only with color. When forced colors remove the colors, checked and unchecked look the same; create the mark only while :checked.
Third, writing outline: none after appearance: none and losing the focus ring. Do not override the global :focus-visible rule.
Fourth, giving inputs a font smaller than 16 pixels. iOS Safari zooms the page on focus; font: inherit and 16 pixel body text prevent that.
Fifth, writing only the unprefixed user-select for Safari. It has no effect before Safari 27.3; write -webkit-user-select too.
Baseline Status
node scripts/baseline.mjs color-scheme accent-color appearance caret-color input-range field-sizing customizable-select cursor pointer-events user-select resize interactivity
color-scheme widely 2024-08-03 Chrome 98, Firefox 96, Safari 13, iOS 13
accent-color not Baseline Chrome 93, Firefox 92, Safari 26.2, iOS 26.2
appearance widely 2024-09-14 Chrome 84, Firefox 80, Safari 15.4, iOS 15.4
caret-color widely 2022-07-15 Chrome 57, Firefox 53, Safari 11.1, iOS 11.3
input-range widely 2019-09-16 Chrome 4, Firefox 23, Safari 3.1, iOS 5
field-sizing newly 2026-06-16 Chrome 123, Firefox 152, Safari 26.2, iOS 26.2
customizable-select not Baseline Chrome 135, Firefox -, Safari 27, iOS 27
cursor not Baseline Chrome 68, Firefox 27, Safari 11, iOS -
pointer-events widely 2018-01-29 Chrome 2, Firefox 3.6, Safari 4, iOS 3.2
user-select not Baseline Chrome 54, Firefox 69, Safari -, iOS -
resize not Baseline Chrome 4, Firefox 5, Safari 4, iOS -
interactivity not Baseline Chrome 135, Firefox -, Safari -, iOS -
| Feature | Status | Note |
|---|---|---|
color-scheme, appearance, caret-color | Widely available | appearance widely since 2024-09 |
accent-color | Not Baseline | Chrome Android does not keep minimum contrast |
field-sizing | Newly available (2026-06) | rows and resize where it is missing |
appearance: base-select | Not Baseline | Chrome 135, Safari 27; not in Firefox |
pointer-events | Widely available | Does not disable |
user-select | Not Baseline | -webkit- prefix until Safari 27.3 |
cursor, resize | Not Baseline | Because of iOS; everywhere on desktop |
interactivity: inert | Not Baseline | Chromium only; HTML inert is widely available |
A Small Experiment
★☆☆ Set the accent color of #light-check in the lab to #1d4ed8 and verify in 12-forms.mjs that both engines use the color unchanged.
★★☆ Turn the radios of Bookstore's shipping options into circles with appearance: none; show the selected state with an inner circle (::before, only while :checked) and apply the lab's forced colors measurement to these radios.
★★★ Add code to the stepper that makes the matching button disabled at min and max; measure that a disabled button cannot be clicked with the mouse or with Enter by adding a line to 12-pages.mjs.
The next and last post moves on to tables and printing: border-collapse, table-layout: fixed, tabular-nums for money columns, a sticky table header, @media print, @page and print-color-adjust; Bookstore's "My orders" page and a printable invoice.