Кастомні чекбокси й радіокнопки на чистому CSS: доступні патерни, що не брешуть

Було корисно?

Ви запускаєте нову систему дизайну. Маркетинг у захваті від «чистих» чекбоксів. Попри це приходять тікети в підтримку: «не видно, що обрано», «клавіатура не працює», «екранний читець каже «порожньо»». Тим часом ваша SRE-голова кричить: ми щойно запустили регресію UI з тією ж енергією, що й погана конфігурація — тихо спочатку, потім дорого.

Кастомні елементи форм не складні. Брехливі елементи форм — складні. Ця стаття про те, як будувати кастомні чекбокси/радіо на чистому CSS, зберігаючи нативні семантику, поведінку клавіатури та стійкість у режимі високої контрастності. Жодних JS-фокусів, жодного театру доступності.

Незмінні умови: що насправді означає «доступно» для чекбоксів/радіо

Чекбокси та радіокнопки навмисно нудні. Вони — одні з найстандартизованіших елементів взаємодії на веб-платформі. Браузер дає семантику, поведінку клавіатури, управління фокусом, підключення до API доступності та сумісність із допоміжними технологіями — фактично невелике диво, яке працює на мільярдах пристроїв.

Коли команди «кастомізують» їх, найпоширеніша помилка — замінити це диво на div і настрій. Іноді це навіть проходить поверхневий аудит: виглядає клікабельним і перемикається мишкою. Але воно провалюється, коли ви намагаєтесь пройти табом, увімкнути високий контраст, збільшити масштаб до 200% або запустити екранний читець. В операційних термінах: це проходить у стейджингу, де всі на однакових MacBook; в продакшні — флот реальний, і все розвалюється.

Визначення: кастомний контрол, який не бреше

Кастомний чекбокс/радіо «не бреше», якщо він відповідає цим вимогам:

  • Нативний елемент залишається джерелом правди. Використовуйте <input type="checkbox"> / <input type="radio">. Не відтворюйте їхню поведінку в JS.
  • Маркування реальне. Використовуйте <label for="…"> або обгортайте input у label, щоб кліки/тапи працювали без хаків.
  • Клавіатура працює за замовчуванням. Tab фокусується на елементі; Space переключає чекбокс; стрілки пересувають вибір у групах радіо (за правилами браузера).
  • Фокус видно. Особливо з :focus-visible. Жодних «тонких тіней», що зникають на сонці.
  • Підтримуються стани. Checked/unchecked, disabled, invalid, і (для чекбоксів) indeterminate.
  • Високий контраст і forced-colors не ламають його. Якщо ви малюєте все графікою фону, forced-colors зробить ваш контрол примарним.

Думка: Якщо ви не можете зберегти нативний input в DOM, не робіть кастомні чекбокси. Виберіть інший візуальний дизайн, який працює з платформою. Ваші бренд-правила не покриють ADA-позов.

Цитата, яку варто приклеїти на монітор

«Надія — це не стратегія.» — Гордон Р. Діксон

Це не веб-цитата, але інженерна істина: сподівання, що ваш кастомний UI поводитиметься як чекбокс, — це спосіб відправити аутедж у людській подобі.

Факти та історичний контекст, які варто знати

Трохи контексту робить сучасні обмеження менш довільними. Ось конкретні факти, що впливають на дизайн:

  1. Ранні веб-форми будувалися за зразком паперових форм. Чекбокси та радіо мали бути передбачуваними, а не брендовими. Ця «нудна» база — причина їхньої великої сумісності.
  2. CSS довго не давав надійного контролю над нативними елементами. Браузери трактували інпути як OS-віжети з обмеженими гачками; «кастомні контролі» стали індустрією хаків.
  3. Ассоціація label → input старша за більшість фреймворків. Шаблон for/id — фундаментальна юзабіліті-ознака, а не додаток для доступності.
  4. Радіогрупи мають вбудовану семантику клавіатури. Навігація стрілками та взаємовиключний вибір обробляються браузером, коли name однаковий.
  5. :focus-visible з’явився через зловживання outline. Дизайнери прибирали контури; користувачі губилися. Браузери відповіли розумнішим евристикою.
  6. Forced-colors режим не нішевий. Windows High Contrast (і сучасні forced colors) використовують люди, які не бачать низькоконтрастні UI — не ті, хто милується вашими градієнтами.
  7. SVG-іконки — не семантика доступності. Малювання галочки не додає елементу в дерево доступності. Семантику дає input.
  8. Indeterminate — реальний стан. Це UI-стан, а не значення; він не відправляється як «можливо». Часто використовується для «Вибрати все» з частковим вибором дітей.
  9. Браузери відрізняються в розмірах та вирівнюванні за замовчуванням. Якщо спиратися на дефолтні метрики, ваш піксельно-ідеальний макет буде дрейфувати. Якщо замінити семантику, ваша поведінка дрейфуватиме. Вибирайте, що вам прийнятніший дрейф.

Це не дрібниці. Кожен пункт — причина, чому патерн «div-чекбокс» постійно ламається у реальних користувачів.

Патерни, що працюють з чистим CSS (і чому)

Патерн A: Візуально приховати нативний input, стилізувати сусіда

Це робоча конячка. Ви зберігаєте реальний input у DOM, фокусований і інтерактивний, але візуально прихований. Потім стилізуєте span (або подібний) як «коробку/коло» за допомогою селекторів input:checked + .control.

Чому працює: браузер досі володіє поведінкою, допоміжні технології бачать чекбокс/радіо, форми правильно відправляються, і ви можете темаувати за допомогою CSS.

Чому ламається: люди ховають input через display:none або visibility:hidden (видаляє з фокусу/AT). Або навісили його під чимось, але порушили pointer-events. Або забули про стилі фокусу.

Патерн B: Стилізувати сам input через appearance (обережно)

Сучасний CSS дає appearance: none у багатьох браузерах, що дозволяє переробити нативний input напряму. Це може бути чисто. Також може зламатися в forced-colors, через платформні особливості та в старих браузерах.

Моя порада: використовуйте це тільки якщо ваш підтримуваний стек сучасний і ви явно тестуєте forced colors та масштаб. Інакше Патерн A надійніший.

Патерн C: Використовуйте accent-color, коли треба просто брендова колонка

Якщо ваша ціль — «зробити чекбокси сині», а не «вигадати новий чекбокс», застосуйте accent-color. Воно зберігає нативне рендерення, але змінює колір підсвітки. Це найменш ризикований варіант і найменш захопливий — тому ідеальний.

Правило: Чим «кастомніший» ваш чекбокс виглядає, тим більше операційного тестування потрібно. Розглядайте кастомні контролі як виробничу залежність, а не як прикрасу CSS.

Чого уникати: role=checkbox на div

Так, ARIA має role="checkbox". Ні, воно не дає вам миттєвого паритету з нативними input. Ви підписуєтесь на імплементацію клавіатури, фокусу, асоціації міток, інтеграції з формами, станів disabled і нюансів екранних читалок. Ви також підписуєтесь на те, щоб помилитися принаймні в одному поєднанні браузер/AT, яке ви не контролюєте.

Якщо мусите (вбудований додаток, без форм, екстремальні обмеження), пишіть це як компонент з SLO, тестами по AT і планом відкату. Інакше: не робіть цього.

CSS-рецепти: чекбокс, радіо та «перемикач» без брехні

Базова HTML-структура, що масштабується

Ця структура нудна. У цьому й сенс. Кожен варіант — label, що обгортає input та візуал. Це створює велику ціль для кліку й зберігає асоціацію без залежності від id.

cr0x@server:~$ cat controls.html
<fieldset class="choices">
  <legend>Notification settings</legend>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="email_alerts">
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">Email alerts</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="sms_alerts" disabled>
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">SMS alerts (disabled)</span>
  </label>
</fieldset>

<fieldset class="choices">
  <legend>Pager escalation</legend>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="none">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">None</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="critical">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">Critical only</span>
  </label>
</fieldset>

Зауважте aria-hidden="true" на декоративному span. Semантику вже надає input; не варто, щоб прикраса з’являлася в дереві доступності.

CSS: візуально приховано, але функційно активне

Ось ключовий момент: «візуально приховано» означає фокусоване й дотупне в дереві доступності. Не використовуйте display:none. Не використовуйте visibility:hidden. Це еквівалент вимкнення диска, бо вам не подобається індикатор.

cr0x@server:~$ cat controls.css
.choices {
  border: 1px solid #d0d7de;
  border-radius: 10px;
  padding: 12px 14px;
  margin: 14px 0;
}

.choice {
  display: grid;
  grid-template-columns: 1.4rem 1fr;
  align-items: start;
  gap: 0.6rem;
  padding: 0.45rem 0;
  cursor: pointer;
}

.choice__input {
  /* Visually hidden but still focusable */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.choice__control {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid #5b6472;
  border-radius: 0.35rem;
  display: inline-grid;
  place-items: center;
  background: #fff;
  box-sizing: border-box;
}

.choice__control--radio {
  border-radius: 999px;
}

.choice__text {
  color: #111;
}

/* Checked */
.choice__input:checked + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:checked + .choice__control::after {
  content: "";
  width: 0.65rem;
  height: 0.65rem;
  background: #fff;
  border-radius: 0.18rem;
}

.choice__input:checked + .choice__control--radio::after {
  border-radius: 999px;
  width: 0.55rem;
  height: 0.55rem;
}

/* Focus */
.choice__input:focus-visible + .choice__control {
  outline: 3px solid #0b5fff;
  outline-offset: 2px;
}

/* Disabled */
.choice__input:disabled + .choice__control {
  border-color: #9aa4b2;
  background: #f1f3f5;
}

.choice__input:disabled ~ .choice__text {
  color: #667085;
}

.choice:has(.choice__input:disabled) {
  cursor: not-allowed;
}

/* Forced colors */
@media (forced-colors: active) {
  .choice__control {
    forced-color-adjust: none;
    border-color: CanvasText;
    background: Canvas;
  }
  .choice__input:checked + .choice__control {
    background: Highlight;
    border-color: Highlight;
  }
  .choice__input:checked + .choice__control::after {
    background: HighlightText;
  }
  .choice__input:focus-visible + .choice__control {
    outline-color: Highlight;
  }
}

Важливо: тут використано :has() для стилю курсора. Якщо потрібно підтримувати браузери без нього — приберіть це правило й погодьтеся на менш досконалий курсор. Не замінюйте це JS. Правильність курсора не варта семантичного ризику.

Indeterminate чекбокси: стан, про який всі забувають

Indeterminate — не значення, до якого користувач може напряму переключитись через нативний чекбокс; зазвичай застосунок встановлює його, коли дочірні елементи вибрані частково. Ви все ще можете стилізувати це через CSS, використовуючи псевдоклас :indeterminate.

cr0x@server:~$ cat indeterminate.css
.choice__input:indeterminate + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:indeterminate + .choice__control::after {
  content: "";
  width: 0.7rem;
  height: 0.15rem;
  background: #fff;
  border-radius: 999px;
}

Встановлення indeterminate зазвичай потребує JS (бо HTML не дозволяє його задекларувати). Але ви можете зберегти нативну поведінку: JS встановлює input.indeterminate = true; CSS його стилізує. Це чесно.

Слово про перемикачі (toggle switches)

Усі хочуть iOS-перемикач. Але більшість «switch»-компонентів — просто чекбокс у костюмі. Це може бути нормально, якщо ви не брешете щодо його сутності: використовуйте чекбокс, чітко маркуйте й не нав’язуйте ARIA-role без вагомої причини. Input залишається контролем; перемикач — декор.

Жарт №1 «Кастомний перемикач» на div — як RAID 0 із почуттів: швидко відправити, катастрофічно довіряти.

Усі стани, які потрібно підтримувати (і як вони ламаються)

Checked vs unchecked

Візуально це найпростіше, і одночасно найпростіше випадково інвертувати. Бачив CSS, який малював галочку, коли елемент був unchecked, бо хтось поміняв селектори під час рефакторингу. Якщо ваші візуали й фактичне значення розходяться, ви створили UI, що брешуть.

Фокус і навігація клавіатурою

Користувачі клавіатури — не нішеві. Це power-користувачі, люди з порушеннями моторики, стендикі й інженери, що люблять Tab за швидкість. Критичні речі:

  • Tab має досягати контролю в передбачуваному порядку.
  • Фокус має бути видимим, коли досягнуто.
  • Space перемикає чекбокси і активує радіо.
  • У межах групи радіо стрілки навігації переміщують опції (деталі залежать від браузера), але поведінка фокусу має залишатись зрозумілою.

Якщо ви неправильно ховаєте input, фокус зникає. Якщо підміняєте input на div, скоріш за все забудете Space або стрілки. Якщо прибираєте контури, фокус стає полюванням на скарби.

Disabled

Disabled-контролі потребують і семантичного disabled (атрибут disabled), і візуального disabled (кольори/курсори). Не лише «затемнюйте» його. Це виглядати як disabled, але продовжувати перемикатися — еквівалент файлової системи лише для читання, що все одно приймає записи, поки не впаде.

Invalid і повідомлення про помилку

Групи чекбоксів часто провалюють валідацію («Ви маєте погодитись з умовами»). Контрол має підтримувати :invalid і/або явний клас помилки. Повідомлення про помилку треба програмно асоціювати (зазвичай через aria-describedby на input або group). Чистий CSS може обробити візуал, семантика потребує дисципліни в HTML.

High contrast і forced colors

Якщо галочка — background-image, forced colors її ймовірно проігнорують. Тому рецепт використовує рамки й фони, плюс forced-color-adjust і системні кольори типу CanvasText. Мета — не зберегти вашу палітру, а зберегти зміст.

Збільшення масштабу, великий текст і сенсорні цілі

При 200% масштабі ваш 12px чекбокс стає тонким інструментом. Використовуйте обгортку label і щедрі відступи, щоб ціль для тапу була великою. У корпоративних додатках багато користувачів на сенсорних ноутбуках. Маленькі контролі перетворюються на «чому це не працює?» повідомлення.

Три корпоративні міні-історії з практики

Інцидент: неправильне припущення, що перетворило згоду на хаос

Продуктова команда викотила редизайн екрану згоди: категорії cookie, опції маркетингу, звична тарілка відповідності. Новий дизайн використовував кастомні стилізовані чекбокси, реалізовані як div з обробниками кліків. Хтось додав role="checkbox" і aria-checked і подумав, що цього достатньо.

Переважно це працювало мишкою. Працювало в улюбленому браузері дизайнера. Інцидент почався тихо: служба підтримки почала отримувати розрізнені скарги «не можу відмовитись» або «чекбокс постійно повертає значення». Повідомлення були непослідовні. Це найнебезпечніше.

Потім включилось юридичне. Один користувач записав сесію: при навігації клавіатурою Tab пропускав деякі контролі, а Space скролив сторінку замість перемикання. UI візуально показував «unchecked», але бекенд стану фактично був «checked», бо обробник кліку спрацьовував під час кліків по мітках у дивних сценаріях. Різні шляхи — різний стан. UI згоди, що ненадійно відображає фактичне значення — це не баг дизайну; це операційний ризик з регуляторними наслідками.

Виправлення було некрасиве: викинули div-контролі, повернули нативні input та стилізували їх сусідніми span. Команда також додала smoke-тест доступності в CI. Не тому, що стали святими, а тому, що не хотіли ще однієї крос-функціональної війни через чекбокс.

Оптимізація, яка обернулась проти

Інша компанія мала інструмент з великою кількістю форм. Скарги на продуктивність були реальні: старі ноутбуки, великі таблиці, багато контролів. Хтось запропонував «оптимізацію»: прибрати зайву розмітку для кастомних контролів, стилізувати input напряму з appearance:none і більше не обгортати лейблами. Менше DOM, швидший рендер — на папері.

Результат: помітне покращення часу початкового рендеру в одному бенчмарку. А далі відкат. Цілі кліків зменшились, бо лейбли більше не обгортали текст. Користувачі почали промахуватись; рівень помилок виріс. Підтримка заповнилася повідомленнями «не збереглося», які насправді були «я не потрапив у маленьке віконце».

Гірше: кільця фокусу були непослідовні в різних браузерах при стилізації input напряму. Деякі комбінації CSS обрізали індикатор фокусу через overflow в контейнерах. Користувачі клавіатури фактично сліпли. Виграш продуктивності з’ївся втраченими продуктивністю та хвостами «це зламалося».

Урок не в «ніколи не оптимізувати DOM». Урок: оптимізуйте там, де це важливо. Вони лишили трохи більшу розмітку (input + control span + text span) і оптимізували рендер в інших місцях: віртуалізація, менше переробок, sane CSS containment. Чекбокс не був вузьким місцем. Він просто став козлом відпущення.

Нудно, але правильно: практика, що врятувала від аутеджу

Команда, що працювала з платежами, мала політику: будь-який кастомний контрол форми має мати письмовий «прийомний тест» для лише-клавіатури, який запускають перед кожним релізом. Ніяких винятків. Це не було популярно у критичні дід-лайни.

Одної п’ятниці дизайнер запушив «дрібне» візуальне змінення: сховати нативний чекбокс через display:none, бо «все ще видний піксель». Інша частина контролю виглядала нормально. Мишачі кліки все одно працювали, бо label-клік викликав JS (так, JS був). Це могло б полетіти в продакшн.

Прийомний тест спіймав це за хвилини: Tab більше не фокусував чекбокс. Вивід екранного читалки змінився. Команда відкотила CSS і використала коректний visually-hidden патерн. У продакшн регресія не потрапила. Ніхто не отримав пейджінг через UI-зміну — найкращий вид інциденту: той, якого не сталося.

Політика здавалася бюрократією, поки не запобігла високопакідному провалу в критичному каналі. Нудні практики часто є просто надійністю з папочкою.

Швидкий план діагностики

Коли кастомні чекбокси/радіо «відчуваються зламаними», не починайте з підгонки кольорів. Почніть з доведення семантики й поведінки. Ось швидка, виробнича послідовність, що швидко знаходить вузьке місце.

Перше: переконайтесь, що нативний input існує і на нього можна сфокусуватись

  • Чи можете ви перейти туди табом?
  • Чи переключає його Space?
  • Чи з’являється де-небудь видиме кільце фокусу?

Якщо ні: ваш input неправильно приховано (display:none, visibility:hidden), він поза екраном без стилю фокусу або перекритий іншим елементом.

Друге: підтвердіть асоціацію мітки і ціль кліку

  • Клікніть по тексту, а не по коробці. Чи переключається?
  • Чи надійно працює тап на мобільному?

Якщо ні: label не пов’язаний, або pointer-events перехоплює декоративний елемент.

Третє: підтвердіть паритет стану (візуал vs фактичне)

  • Перевірте властивість checked інпута в devtools під час переключення.
  • Відправте форму й перевірте payload.

Якщо UI показує checked, але input unchecked (або навпаки), у вас «брехливий контрол». Зупиніть і виправте джерело істини: input має володіти станом.

Четверте: forced colors і масштаб

  • Спробуйте forced colors (Windows) або емулюйте, де можливо.
  • Збільшіть масштаб до 200% і перевірте, чи працює зона попадання.

Якщо тут провал: ви покладаєтесь на неадаптивні візуали (фон-зображення, градієнти, тонкі контури) або крихітні цілі для кліку.

П’яте: поведінка групи радіо

  • Підтвердьте, що всі радіо мають однаковий name.
  • Перейдіть стрілками по опціях і подивіться правила вибору.

Якщо це провалюється: інпути не є справжніми радіо, або DOM-структура заважає фокусу/взаємодії.

Жарт №2 Налагодження кастомних радіо — як налагодження DNS: це ніколи не радіо, поки таки не воно.

Практичні завдання: команди, виводи, рішення

Це реальні завдання, які можна виконати на робочій станції або в CI, щоб діагностувати «брехливі контролі». Кожне завдання містить команду, приклад виводу, що це означає та наступні рішення. Мета — швидко зменшити невизначеність.

Завдання 1: Переконатись, що input існує і не стоїть display-none

cr0x@server:~$ rg -n 'display\s*:\s*none|visibility\s*:\s*hidden' src/ styles/
src/components/Choice.css:41:  display: none;

Вивід означає: У стилях застосовано display:none — часто до input.

Рішення: Замініть на патерн visually-hidden, що зберігає фокус/AT. Якщо правило застосовано до input — вважайте це Sev-2 багом у бібліотеці UI.

Завдання 2: Знайти реалізації «чекбоксу» на div

cr0x@server:~$ rg -n 'role="checkbox"|role="radio"|aria-checked' src/
src/components/LegacyToggle.tsx:17: return <div role="checkbox" aria-checked={checked} ...>

Вивід означає: Хтось реалізує семантику чекбокса вручну.

Рішення: Проведіть аудит обробки клавіатури та маркування. Якщо це елемент форми — заплануйте заміну на нативні input, якщо немає жорстких обмежень.

Завдання 3: Перевірити групування радіо по name

cr0x@server:~$ rg -n 'type="radio"' src/ | head
src/pages/Preferences.html:88: <input type="radio" name="pager" value="none">
src/pages/Preferences.html:94: <input type="radio" name="pager" value="critical">

Вивід означає: Можна побачити, чи name узгоджений у групі.

Рішення: Якщо імена різні — виправте. Якщо імена однакові, але поведінка дивна — перевірте перехоплення клавіатурних подій у JS або вкладені інтерактивні елементи.

Завдання 4: Перевірити відсутність міток (labels)

cr0x@server:~$ rg -n '<input[^>]+type="checkbox"|<input[^>]+type="radio"' src/ | head -n 20
src/pages/Checkout.html:211: <input type="checkbox" id="tos">

Вивід означає: Інпути існують; тепер треба переконатися в асоціації міток.

Рішення: Переконайтесь, що є відповідний <label for="tos"> або що input обгорнутий у label. Якщо ні — додайте. Не «вирішуйте» це обробниками кліків.

Завдання 5: Зловити перехоплення pointer-events декоративними елементами

cr0x@server:~$ rg -n 'pointer-events\s*:\s*auto|pointer-events\s*:\s*none' src/styles/
src/styles/controls.css:77: .choice__control { pointer-events: auto; }

Вивід означає: Декоративні елементи можуть перехоплювати кліки/тапи.

Рішення: Зазвичай виставити декоративний control як pointer-events:none і дозволити label обробляти взаємодію, якщо немає специфічної причини інакше. Потім повторно протестуйте мобільний тап.

Завдання 6: Запустити axe-core перевірки в CI (headless)

cr0x@server:~$ npx playwright test --project=chromium --grep "@a11y"
Running 6 tests using 1 worker
✓ 6 passed (18.2s)

Вивід означає: Автоматичні a11y-тести пройшли (у межах їхнього покриття).

Рішення: Залиште їх, але не зупиняйтесь на цьому. Додайте скриптовані тесті клавіатурної навігації; axe не впіймає все про «відчуття» або forced-colors.

Завдання 7: Валідовати контраст кольору для кілець фокусу та станів

cr0x@server:~$ node -e 'console.log("Manual check: verify focus ring color against backgrounds in design tokens")'
Manual check: verify focus ring color against backgrounds in design tokens

Вивід означає: Нагадування: контраст частково вимірюваний, частково контекстний.

Рішення: Переконайтесь, що колір кільця фокусу відповідає вимогам контрасту на світлих і темних фонах. Якщо у системі є теми — тестуйте обидві.

Завдання 8: Виявити використання background-images для галочок

cr0x@server:~$ rg -n 'background-image|mask-image|data:image' src/styles/
src/styles/checkbox.css:19: background-image: url("check.svg");

Вивід означає: Галочки малюються через зображення/маски.

Рішення: Якщо ви підтримуєте forced-colors, замініть на CSS-фігури (border/background) або забезпечте overrides для forced-colors, щоб стани були видимими.

Завдання 9: Перевірити видалення outline

cr0x@server:~$ rg -n 'outline\s*:\s*none|outline\s*:\s*0' src/styles/
src/styles/reset.css:12: *:focus { outline: none; }

Вивід означає: Глобальний reset вбиває індикатори фокусу по всьому сайту.

Рішення: Приберіть це або замініть на :focus-visible стилі. Глобальне видалення outline — баг надійності; воно ламає навігацію під навантаженням (і під аудитами).

Завдання 10: Перевірити, що форми відправляють очікувані значення

cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlencode
payload = {"email_alerts": "on", "pager": "critical"}
print(urlencode(payload))
PY
email_alerts=on&pager=critical

Вивід означає: Чекбокс, що відмічено, за замовчуванням відправляє своє ім’я зі значенням «on»; радіо відправляє вибране value.

Рішення: Якщо бекенд очікує інші значення, задайте явний value для чекбоксів або трансформуйте на сервері. Не вигадуйте клієнтський стан, відмінний від input.

Завдання 11: Переконатись, що indeterminate не плутають з checked

cr0x@server:~$ node - <<'NODE'
console.log("Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.")
NODE
Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.

Вивід означає: Нагадування про типову логічну помилку: трактувати indeterminate як «true».

Рішення: Переконайтесь, що логіка встановлює і checked, і indeterminate явно на основі вибору дочірніх елементів, і що логіка відправки враховує лише checked.

Завдання 12: Smoke-тест порядку фокусу за допомогою скрипту клавіатури

cr0x@server:~$ npx playwright test -g "keyboard navigation"
Running 1 test using 1 worker
✓ 1 passed (4.9s)

Вивід означає: Тест клавіатурної навігації пройшов. (Якщо такого тесту немає — він зламається, бо не існує. Ось у чому суть.)

Рішення: Додайте перевірки, що Tab досягає input, що Space його переключає, і що кільце фокусу присутнє. Розглядайте це як регресійний тест для критичного API.

Завдання 13: Перевірити обчислені стилі для forced-colors (Windows CI)

cr0x@server:~$ node -e 'console.log("Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.")'
Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.

Вивід означає: Forced colors залежить від платформи; потрібне відповідне середовище.

Рішення: Якщо доступність у сфері відповідальності — додайте хоча б одну Windows CI-ланку або ручний крок тестування Windows перед релізами, що торкаються контролів.

Завдання 14: Виявити випадкове використання tabindex на декоративних span

cr0x@server:~$ rg -n 'tabindex=' src/components/
src/components/Choice.tsx:23: <span class="choice__control" tabindex="0"></span>

Вивід означає: Декоративні елементи роблять фокусованими, що порушує порядок табу і плутає екранні читалки.

Рішення: Приберіть tabindex з неінтерактивних елементів. Тримайте фокус на нативному input. Якщо потрібна більша область фокусу — використовуйте стилі label і padding.

Типові помилки: симптом → корінь → виправлення

Симптом Корінь проблеми Виправлення
Tab пропускає чекбокс/радіо повністю Input приховано через display:none або видалено з DOM; або неправильно заданий tabindex Використайте патерн visually-hidden (clip/1px) і переконайтесь, що тільки input фокусований
Space не перемикає; сторінка скролиться замість цього Не реальний input; використовується div з обробниками кліків; відсутня обробка keydown Використовуйте нативні input. Якщо мусите ARIA-ролі — реалізуйте повну клавіатурну підтримку (і прийміть необхідність підтримки)
Клік по тексту мітки не переключає Відсутня асоціація мітки (немає for/id, або input не обгорнутий) Обгорніть input у label або правильно зв’яжіть через for; приберіть JS-хаки кліків
Кільце фокусу є, але невидиме Outline видалено в reset; колір кільця має низький контраст; обрізано через overflow Використовуйте :focus-visible зі достатнім контрастом; уникайте обрізання контейнерами або додайте outline-offset/простір
Візуальний checked не відповідає відправленому значенню Візуальний стан керується окремо (зміна класів) від checked input Зробіть input єдиним джерелом істини; стилізуйте через селектори :checked тільки
Режим високого контрасту показує порожні квадрати Галочки малюються зображеннями/масками; кольори переопреділені forced-colors Використовуйте CSS-фони/рамки і додайте @media (forced-colors: active) з системними кольорами
Група радіо дозволяє множинний вибір Інпути не мають однакового name; або не справжні радіо Переконайтесь у однаковому name у групі; залишайте нативні radio
Сенсорні користувачі скаржаться «важко натиснути» Мала зона попадання; клікабельний лише короб; текст не клікабельний; відступи занадто малі Обгорніть label; додайте padding і відступи; враховуйте мінімальну ціль близько 44px
Екранний читець оголошує «група», але не опції чітко Відсутні fieldset/legend для згрупованих контролів; або некоректне маркування Використовуйте <fieldset> і <legend> для груп; переконайтесь, що кожен input має мітку
Disabled виглядає як disabled, але все ще переключається Лише зовнішній вигляд; атрибут disabled відсутній Встановіть disabled на input; стилізуйте :disabled стани; приберіть JS-перемикання

Чек-листи / покроковий план

Покроково: будівництво надійного кастомного компонента чекбокса/радіо (чистий CSS)

  1. Почніть з нативного HTML. Використовуйте input + label. Для груп — fieldset + legend.
  2. Визначте рівень кастомізації. Якщо accent-color вирішує завдання — зупиніться на цьому.
  3. Оберіть Патерн A або B. Віддавайте перевагу Патерну A (прихований input + стилізований сусід) для надійності.
  4. Правильно реалізуйте visually-hidden input. Використовуйте clip/1px; ніколи не ставте display:none.
  5. Стилізуйте стани через селектори. Застосовуйте :checked, :disabled, :focus-visible, :indeterminate.
  6. Зробіть фокус однозначним. Використовуйте помітний outline з offset. Не покладайтесь на тонкі тіні.
  7. Підтримуйте forced colors. Додайте @media (forced-colors: active) і використовуйте системні кольори.
  8. Перевірте розмір цілі для кліку. Wrapper label, padding і відступи повинні робити вибір легким при 200% масштабі.
  9. Тест клавіатурою. Tab, Shift+Tab, Space; навігація стрілками в radio.
  10. Тест щонайменше одним шляхом екранного читалки. Навіть базовий smoke-test виявить очевидні проблеми з маркуванням.
  11. Додайте регресійні тести. Axe-перевірки плюс скриптований тест клавіатурної навігації.
  12. Випускайте з планом відкату. Якщо це зміна в системі дизайну, ставте її як оновлення спільної бібліотеки.

Чек-лист релізу (що я б вимагав у продакшн-організації)

  • Кільце фокусу видиме в світлих і темних темах
  • Прохід клавіатурою: кожен контрол досяжний; Space переключає; радіо поводяться як група
  • Прохід мишкою та дотиком: текст мітки переключає; немає крихітних цілей для кліку
  • Forced-colors перевірка (Windows): стани checked та focus залишаються відрізними
  • Відправка форм: payload відповідає візуальному стану; disabled не відправляються
  • Помилки/invalid: повідомлення про помилку асоційоване і видиме
  • Indeterminate (якщо використовується): стиль та логіка стану підтверджені
  • Немає глобального outline:none у фінальній CSS

Операційний підхід: кастомні контролі — це спільна інфраструктура. Якщо вони ламаються, ламається все вниз по спектру: онбординг, оплата, налаштування, згода. Ставтесь до них як до критичного сервісу.

FAQ

1) Чи можна ховати input через opacity: 0 замість clip-техніки?

Іноді можна. Але opacity:0 інпути все одно займають місце в макеті і можуть створювати дивні області кліку. Патерн clip/1px для visually-hidden передбачуваніший і широко використовується для доступності.

2) Чи колись display:none підходить для input?

Не якщо цей input — інтерактивний контрол. display:none видаляє його з дерева доступності і з навігації клавіатурою. Якщо input суто надлишковий (рідкість), можливо — але тоді він непотрібний.

3) Чи варто використовувати role="switch" для перемикачів?

Тільки якщо вам справді потрібні семантика switch і ви розумієте наслідки. Для багатьох продуктів чекбокс з міткою «Увімкнути X» ясніший і сумісніший. Роль switch підвищує навантаження тестування з AT.

4) Чи безпечні псевдо-елементи для галочок?

Так, якщо вони чисто декоративні та керовані станом input (:checked + span::after). У forced-colors можуть знадобитись overrides, щоб псевдо-елемент лишався видимим.

5) А SVG для галочки — підходить?

SVG підходить як декор. Не використовуйте SVG замість семантичного контролю. Також перевірте поведінку в forced-colors; деякі SVG-заливки можуть не адаптуватись без додаткової обробки.

6) Чи потрібні ARIA-атрибути на нативних input?

Зазвичай ні. Нативні input вже повідомляють checked/unchecked/disabled. Використовуйте ARIA для опису помилок (aria-describedby) або для груп, якщо не можна застосувати fieldset/legend. Уникайте надлишкових ARIA, що можуть заплутати AT.

7) Чому рекомендовано :focus-visible замість :focus?

:focus-visible зазвичай показує стилі фокусу для клавіатурної та інших непойнтерних інтеракцій, не мерехтячи кільцями при кліках мишкою. Це кращий компроміс за замовчуванням. За потреби можна додати запасні :focus правила.

8) Як обробляти «обов’язковий чекбокс» доступно?

Позначайте чекбокс як required (required) або валідуйте на рівні групи. Надсилайте повідомлення про помилку поруч з контролем і пов’язуйте його через aria-describedby. Візуально стилізуйте :invalid або клас помилки на wrapper.

9) Яка мінімальна тестова матриця для кастомних контролів?

Щонайменше: один браузер на Chromium, один Firefox, один Safari (якщо підтримується), перевірка лише клавіатурою, один тест з екранним читалкою і перевірка forced-colors на Windows, якщо доступність у сфері відповідальності.

10) Якщо я використовую accent-color, чи все одно треба це все?

Потрібно менше CSS-складності, але все одно потрібні маркування, групування і порядок фокусу. accent-color зменшує площу для проблем у forced-colors і невідповідності станів, тож часто це кращий перший крок.

Наступні кроки, які ви зможете зробити цього тижня

Якщо ви працюєте з продакшн-системами, ви вже розумієте цю модель: найнебезпечніші відмови походять від інтерфейсів, що виглядають правильно, але роблять не так, як треба. Кастомні чекбокси і радіо — саме такого роду ризик, якщо їх будувати недбало.

Практичні наступні кроки:

  1. Перелік ваших контролів. Зробіть grep по role="checkbox", reset outline і прихованим інпутам.
  2. Уніфікуйте один чесний патерн. Віддавайте перевагу нативним input + обгортка label + стилізований сусід. Напишіть це один раз, використовуйте скрізь.
  3. Додайте тест клавіатурної навігації. Нехай він голосно падає при регресіях.
  4. Проведіть forced-colors перевірку перед релізом візуального редизайну. Якщо не можете автоматизувати — зробіть це кроком релізу.
  5. Задокументуйте режими відмов. Напишіть «не використовуйте display:none на input» у правилах системи дизайну поруч із токенами.

Зробіть це — і ваші кастомні контролі перестануть брехати. Вони також припинять породжувати тихі, але дорогі хаоси, що руйнують спринти і непомітно навантажують службу підтримки. Нудне — добре. Нудне — надійно.

Для доступних чекбоксів не потрібні героїчні вчинки. Потрібні нативні семантика, чесні стилі й тести, що відповідають тому, як реальні люди користуються інтерфейсом.

← Попередня
MySQL проти PostgreSQL на VPS з 1 ГБ ОЗП: що реально придатне (і які налаштування це забезпечують)
Наступна →
Шифрування ZFS: Надійний захист без втрат продуктивності

Залишити коментар