CSS Checkboxes: 16 Custom Styles + How to Build Your Own

The better-supported checkbox technique is also the more flexible one. MDN marks appearance as widely available since September 2024, while accent-color is still limited availability.

Below are 16 CSS checkboxes you can try and copy, all built on the real input, followed by a guide to accent-color, appearance: none, states and accessibility.

By Bogdan Sandu · Updated October 26, 2026

Native checkboxes, restyled lightly

Three demos that keep the browser-drawn box. accent-color, width and height, and color-scheme are enough when the brand color and the target size are all you need to change.

accent-color with a theme picker

  • accent-color
  • :has()
  • custom property
Accent

Email me when

Pick a swatch, then toggle the rows.

One accent-color declaration on the wrapper recolors every native checkbox inside it, because the property is inherited. The swatches are real radio buttons, and :has() swaps the custom property that feeds accent-color and the button, so no script is involved.

View code
.cb-acc {
  --cb-accent: #0f766e;
  display: grid;
  gap: 16px;
  width: min(100%, 360px);
  accent-color: var(--cb-accent);
  color: #1f2937;
}
.cb-acc:has(input[value="indigo"]:checked) { --cb-accent: #4338ca; }
.cb-acc:has(input[value="crimson"]:checked) { --cb-accent: #be123c; }
.cb-acc:has(input[value="amber"]:checked) { --cb-accent: #b45309; }
.cb-acc__themes {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  border: 0;
}
.cb-acc__themes legend {
  float: left;
  margin-right: 8px;
  padding: 0;
  font-size: 14px;
  font-weight: 600;
}
.cb-acc__sw {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  cursor: pointer;
}
.cb-acc__sw input {
  appearance: none;
  width: 26px;
  height: 26px;
  margin: 0;
  border-radius: 50%;
  background: var(--cb-sw);
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px transparent;
  cursor: pointer;
  transition: box-shadow .15s;
}
.cb-acc__sw input:checked {
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--cb-sw);
}
.cb-acc__sw input:focus-visible {
  outline: 2px solid #111827;
  outline-offset: 5px;
}
.cb-acc__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.cb-acc__card {
  display: grid;
  gap: 4px;
  padding: 20px 22px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 32px rgba(15, 23, 42, .08);
}
.cb-acc__title {
  margin: 0 0 6px;
  font-size: 16px;
  font-weight: 700;
}
.cb-acc__row {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 40px;
  font-size: 15px;
  line-height: 1.35;
  cursor: pointer;
}
.cb-acc__row input {
  flex: none;
  width: 20px;
  height: 20px;
  margin: 0;
  cursor: pointer;
}
.cb-acc__row input:focus-visible {
  outline: 2px solid var(--cb-accent);
  outline-offset: 2px;
}
.cb-acc__save {
  justify-self: start;
  min-height: 44px;
  margin-top: 10px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: var(--cb-accent);
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.cb-acc__save:focus-visible {
  outline: 3px solid var(--cb-accent);
  outline-offset: 3px;
}

Large touch-friendly checkboxes

  • width / height
  • wrapping label
  • 64px rows

Offline downloads

Choose what syncs over mobile data

1.2 GB of 8 GB used on this phone

Click a row, or Tab to a box and press Space.

The box is resized with width and height instead of transform: scale(), so the layout box grows with it and nothing overlaps. Each row is the label, which turns the full 64px row into the tap target while the visible box stays a tidy 28px.

View code
.cb-touch {
  min-height: 100%;
  background: #f8fafc;
  color: #0f172a;
}
.cb-touch__bar {
  padding: 22px 20px 16px;
  background: #fff;
  border-bottom: 1px solid #e2e8f0;
}
.cb-touch__h {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  line-height: 1.2;
}
.cb-touch__sub {
  margin: 4px 0 0;
  color: #475569;
  font-size: 14px;
}
.cb-touch__list {
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
  background: #fff;
  border-top: 1px solid #e2e8f0;
}
.cb-touch__list li {
  border-bottom: 1px solid #e2e8f0;
}
.cb-touch__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: 64px;
  padding: 10px 20px;
  cursor: pointer;
  transition: background-color .15s;
}
.cb-touch__row:has(input:focus-visible) {
  background: #eff6ff;
}
.cb-touch__text strong {
  display: block;
  font-size: 16px;
  font-weight: 600;
}
.cb-touch__text small {
  display: block;
  color: #475569;
  font-size: 13px;
}
.cb-touch__row input {
  flex: none;
  width: 28px;
  height: 28px;
  margin: 0;
  accent-color: #1d4ed8;
  cursor: pointer;
}
.cb-touch__row input:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
.cb-touch__foot {
  margin: 0;
  padding: 16px 20px;
  color: #475569;
  font-size: 13px;
}

Native checkboxes on a dark theme

  • color-scheme: dark
  • accent-color
  • light accent

Export project

Studio Halden / Spring catalogue

Click a row, or Tab to a box and press Space.

color-scheme: dark tells the browser to draw the unchecked boxes with its dark control palette, so they do not glow white on the panel. The accent is a light teal because a dark brand color loses contrast on near-black; Chrome and Firefox then pick a dark checkmark automatically.

View code
.cb-dark {
  display: grid;
  gap: 2px;
  width: min(100%, 340px);
  padding: 22px;
  border: 1px solid #1f2937;
  border-radius: 16px;
  background: #111827;
  color: #e5e7eb;
  color-scheme: dark;
  accent-color: #5eead4;
}
.cb-dark__h {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
}
.cb-dark__sub {
  margin: 0 0 12px;
  color: #9ca3af;
  font-size: 13px;
}
.cb-dark__row {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 40px;
  font-size: 15px;
  cursor: pointer;
}
.cb-dark__row input {
  flex: none;
  width: 20px;
  height: 20px;
  margin: 0;
  cursor: pointer;
}
.cb-dark__row input:focus-visible {
  outline: 2px solid #5eead4;
  outline-offset: 3px;
}
.cb-dark__btn {
  justify-self: start;
  min-height: 44px;
  margin-top: 12px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #5eead4;
  color: #042f2e;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.cb-dark__btn:focus-visible {
  outline: 2px solid #5eead4;
  outline-offset: 3px;
}

Custom boxes with appearance: none

The native rendering is removed and the box, checkmark and states are drawn in CSS on the real input. Every one keeps keyboard focus, label clicks and form submission, and has a forced-colors fallback.

Custom box with a CSS checkmark

  • appearance: none
  • clip-path
  • ::before
Delivery preferences

Click the boxes, or Tab to them and press Space.

appearance: none removes the native box, and the same rule draws a new one with a border and background. The tick is a clip-path polygon on ::before, scaled from 0 to 1 on :checked, so its color comes from one background value and it survives any size.

View code
.cb-tick {
  width: min(100%, 340px);
  margin: 0;
  padding: 18px 22px 12px;
  border: 1px solid #eadfce;
  border-radius: 16px;
  background: #fffbf5;
  color: #292524;
}
.cb-tick legend {
  float: left;
  width: 100%;
  margin-bottom: 6px;
  padding: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
  font-weight: 600;
}
.cb-tick__row {
  clear: both;
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 10px 0;
  font-size: 15px;
  line-height: 1.4;
  cursor: pointer;
}
.cb-tick__row small {
  display: block;
  color: #57534e;
  font-size: 13px;
}
.cb-tick input {
  appearance: none;
  flex: none;
  display: grid;
  place-content: center;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2px solid #57534e;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  transition: background-color .15s, border-color .15s;
}
.cb-tick input::before {
  content: "";
  width: 12px;
  height: 12px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-tick input:hover:not(:checked) {
  border-color: #c2410c;
}
.cb-tick input:checked {
  border-color: #c2410c;
  background: #c2410c;
}
.cb-tick input:checked::before {
  transform: scale(1);
}
.cb-tick input:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-tick input::before {
    transition: transform .15s ease-out;
  }
}
@media (forced-colors: active) {
  .cb-tick input { border-color: ButtonText; }
  .cb-tick input:checked { border-color: Highlight; background: Highlight; }
  .cb-tick input::before { background: HighlightText; }
}

Animated SVG stroke checkmark

  • stroke-dashoffset
  • pathLength
  • adjacent sibling

Invoice INV-2041

$1,280.00

Click the boxes, or Tab to them and press Space.

An inline SVG sits on top of the styled input and ignores pointer events, so clicks still reach the checkbox. pathLength="1" normalises the path, which lets stroke-dasharray and stroke-dashoffset of 1 hide the tick and 0 draw it in, whatever the path geometry.

View code
.cb-draw {
  width: min(100%, 340px);
  padding: 20px 22px 12px;
  border-radius: 18px;
  background: #fff;
  color: #0f172a;
  box-shadow: 0 1px 2px rgba(15, 23, 42, .06), 0 12px 32px rgba(15, 23, 42, .08);
}
.cb-draw__head {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 8px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e2e8f0;
  font-size: 15px;
  font-weight: 700;
}
.cb-draw__head p {
  margin: 0;
}
.cb-draw__head span {
  color: #4338ca;
  font-variant-numeric: tabular-nums;
}
.cb-draw__row {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 46px;
  font-size: 15px;
  cursor: pointer;
}
.cb-draw__box {
  display: grid;
  flex: none;
  width: 26px;
  height: 26px;
}
.cb-draw__box input {
  appearance: none;
  grid-area: 1 / 1;
  box-sizing: border-box;
  width: 26px;
  height: 26px;
  margin: 0;
  border: 2px solid #64748b;
  border-radius: 8px;
  background: #fff;
  cursor: pointer;
  transition: background-color .2s, border-color .2s;
}
.cb-draw__box svg {
  grid-area: 1 / 1;
  width: 26px;
  height: 26px;
  fill: none;
  stroke: #fff;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;
}
.cb-draw__box path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}
.cb-draw__box input:checked {
  border-color: #4f46e5;
  background: #4f46e5;
}
.cb-draw__box input:checked + svg path {
  stroke-dashoffset: 0;
}
.cb-draw__box input:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-draw__box path {
    transition: stroke-dashoffset .3s ease-out .05s;
  }
}
@media (forced-colors: active) {
  .cb-draw__box input { border-color: ButtonText; }
  .cb-draw__box input:checked { border-color: Highlight; background: Highlight; }
  .cb-draw__box svg { stroke: HighlightText; }
}

Round checkboxes

  • border-radius: 50%
  • clip-path tick
  • grid rows
Extra toppings Margherita, 12 inch

Click the boxes, or Tab to them and press Space.

A round checkbox is the square build with border-radius: 50% on the input. It is still a checkbox, so several toppings can be picked at once; if only one choice should be allowed, use radio buttons instead of making checkboxes behave like them.

View code
.cb-round {
  width: min(100%, 340px);
  margin: 0;
  padding: 18px 20px 10px;
  border: 0;
  border-radius: 24px;
  background: #fff;
  color: #1c1917;
  box-shadow: 0 10px 30px rgba(28, 25, 23, .08);
}
.cb-round legend {
  float: left;
  width: 100%;
  margin-bottom: 8px;
  padding: 0;
  font-size: 17px;
  font-weight: 700;
}
.cb-round legend span {
  display: block;
  color: #57534e;
  font-size: 13px;
  font-weight: 400;
}
.cb-round__row {
  clear: both;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  border-top: 1px solid #f0ebe6;
  font-size: 15px;
  cursor: pointer;
}
.cb-round__row em {
  color: #57534e;
  font-style: normal;
  font-variant-numeric: tabular-nums;
}
.cb-round input {
  appearance: none;
  display: grid;
  place-content: center;
  box-sizing: border-box;
  width: 26px;
  height: 26px;
  margin: 0;
  border: 2px solid #57534e;
  border-radius: 50%;
  background: #fff;
  cursor: pointer;
  transition: background-color .15s, border-color .15s;
}
.cb-round input::before {
  content: "";
  width: 12px;
  height: 12px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-round input:checked {
  border-color: #15803d;
  background: #15803d;
}
.cb-round input:checked::before {
  transform: scale(1);
}
.cb-round input:focus-visible {
  outline: 2px solid #15803d;
  outline-offset: 3px;
}
.cb-round__row:has(input:checked) span {
  font-weight: 600;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-round input::before {
    transition: transform .18s cubic-bezier(.3, 1.6, .6, 1);
  }
}
@media (forced-colors: active) {
  .cb-round input { border-color: ButtonText; }
  .cb-round input:checked { border-color: Highlight; background: Highlight; }
  .cb-round input::before { background: HighlightText; }
}

Select all with an indeterminate state

  • :indeterminate
  • .indeterminate = true
  • :has() bulk bar

0 selected

Click the boxes, or Tab to them and press Space.

The indeterminate state has no HTML attribute, so a few lines of script set the property whenever some but not all rows are ticked. CSS then draws a dash for :indeterminate, and :has() reveals the bulk actions only when at least one row is selected.

View code
.cb-inbox {
  width: min(100%, 440px);
  overflow: hidden;
  border: 1px solid #e2e8f0;
  border-radius: 16px;
  background: #fff;
  color: #0f172a;
}
.cb-inbox__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  min-height: 56px;
  padding: 8px 16px;
  border-bottom: 1px solid #e2e8f0;
  background: #f8fafc;
}
.cb-inbox__all {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.cb-inbox__count {
  margin: 0;
  color: #475569;
  font-size: 13px;
}
.cb-inbox__actions {
  gap: 6px;
  margin-left: auto;
  display: none;
}
.cb-inbox:has(.cb-inbox__list input:checked) .cb-inbox__actions {
  display: flex;
}
.cb-inbox__actions button {
  min-height: 34px;
  padding: 0 12px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  background: #fff;
  color: #0f172a;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.cb-inbox__actions button:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
.cb-inbox__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.cb-inbox__list li + li {
  border-top: 1px solid #eef2f7;
}
.cb-inbox__list li:has(input:checked) {
  background: #eff6ff;
}
.cb-inbox__msg {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 14px;
  align-items: center;
  padding: 12px 16px;
  cursor: pointer;
}
.cb-inbox__msg input {
  grid-row: span 2;
}
.cb-inbox__from {
  font-size: 14px;
  font-weight: 700;
}
.cb-inbox__subj {
  overflow: hidden;
  color: #475569;
  font-size: 13px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cb-inbox__box {
  appearance: none;
  display: grid;
  place-content: center;
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  margin: 0;
  border: 2px solid #64748b;
  border-radius: 5px;
  background: #fff;
  cursor: pointer;
  transition: background-color .15s, border-color .15s;
}
.cb-inbox__box::before {
  content: "";
  width: 11px;
  height: 11px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-inbox__box:checked,
.cb-inbox__box:indeterminate {
  border-color: #2563eb;
  background: #2563eb;
}
.cb-inbox__box:checked::before {
  transform: scale(1);
}
.cb-inbox__box:indeterminate::before {
  height: 2px;
  width: 10px;
  clip-path: none;
  transform: scale(1);
}
.cb-inbox__box:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-inbox__box::before {
    transition: transform .12s ease-out;
  }
}
@media (forced-colors: active) {
  .cb-inbox__box { border-color: ButtonText; }
  .cb-inbox__box:checked,
  .cb-inbox__box:indeterminate { border-color: Highlight; background: Highlight; }
  .cb-inbox__box::before { background: HighlightText; }
}

Heart toggle on a listing card

  • mask-image
  • @keyframes pop
  • hidden label text
Brick and white stucco houses along a canal, with two small boats moored by a wooden walkway

Canal House 14

Old town, 2 guests, private dock

€148 per night

Click the boxes, or Tab to them and press Space.

An icon checkbox is still a checkbox: the heart is a mask on the input pseudo-element, tinted by background-color, and the label carries visually hidden text so screen readers hear Save Canal House 14 rather than nothing. The pop only plays when motion is allowed.

View code
.cb-fav {
  position: relative;
  width: min(100%, 300px);
  overflow: hidden;
  border-radius: 18px;
  background: #fff;
  color: #1e293b;
  box-shadow: 0 14px 36px rgba(15, 23, 42, .12);
}
.cb-fav img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.cb-fav__save {
  position: absolute;
  top: 12px;
  right: 12px;
  display: grid;
}
.cb-fav__save input {
  appearance: none;
  display: grid;
  place-content: center;
  width: 44px;
  height: 44px;
  margin: 0;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 4px 14px rgba(15, 23, 42, .25);
  cursor: pointer;
}
.cb-fav__save input::before {
  content: "";
  width: 22px;
  height: 22px;
  background: #334155;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.3s-7.6-4.6-9-9.4C2 7.3 4.3 4.5 7.3 4.5c2 0 3.6 1.1 4.7 2.8 1.1-1.7 2.7-2.8 4.7-2.8 3 0 5.3 2.8 4.3 6.4-1.4 4.8-9 9.4-9 9.4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.3s-7.6-4.6-9-9.4C2 7.3 4.3 4.5 7.3 4.5c2 0 3.6 1.1 4.7 2.8 1.1-1.7 2.7-2.8 4.7-2.8 3 0 5.3 2.8 4.3 6.4-1.4 4.8-9 9.4-9 9.4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.cb-fav__save input:checked::before {
  background: #e11d48;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.3s-7.6-4.6-9-9.4C2 7.3 4.3 4.5 7.3 4.5c2 0 3.6 1.1 4.7 2.8 1.1-1.7 2.7-2.8 4.7-2.8 3 0 5.3 2.8 4.3 6.4-1.4 4.8-9 9.4-9 9.4z' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.3s-7.6-4.6-9-9.4C2 7.3 4.3 4.5 7.3 4.5c2 0 3.6 1.1 4.7 2.8 1.1-1.7 2.7-2.8 4.7-2.8 3 0 5.3 2.8 4.3 6.4-1.4 4.8-9 9.4-9 9.4z' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.cb-fav__save input:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 2px;
  box-shadow: 0 0 0 7px #1e293b;
}
.cb-fav__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.cb-fav__body {
  padding: 14px 18px 18px;
}
.cb-fav__title {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
}
.cb-fav__meta {
  margin: 2px 0 8px;
  color: #475569;
  font-size: 14px;
}
.cb-fav__price {
  margin: 0;
  font-size: 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-fav__save input:checked::before {
    animation: cb-pop .35s ease-out;
  }
}
@keyframes cb-pop {
  0% { transform: scale(.6); }
  60% { transform: scale(1.25); }
  100% { transform: scale(1); }
}
@media (forced-colors: active) {
  .cb-fav__save input { border: 2px solid ButtonText; }
  .cb-fav__save input::before { background: ButtonText; }
  .cb-fav__save input:checked::before { background: Highlight; }
}

Checkbox UI patterns

Switches, selectable cards, a to-do list, filter chips and a permission tree. Most of the styling hangs off :checked and :has(), so the input stays the single source of truth for the state.

Toggle switch with role="switch"

  • role="switch"
  • translateX thumb
  • :has() theming

Display

Flip Dark preview.

The track is the input itself with appearance: none, and the thumb is its ::before. role="switch" makes screen readers announce on and off, which fits settings that apply immediately, like the first switch, which darkens the card through :has() with no script.

View code
.cb-sw {
  width: min(100%, 340px);
  padding: 18px 20px 8px;
  border: 1px solid #e2e8f0;
  border-radius: 18px;
  background: #fff;
  color: #0f172a;
  transition: background-color .25s, color .25s, border-color .25s;
}
.cb-sw:has(.cb-sw__dark:checked) {
  border-color: #1e293b;
  background: #0f172a;
  color: #e2e8f0;
}
.cb-sw__h {
  margin: 0 0 4px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.cb-sw__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: 60px;
  cursor: pointer;
}
.cb-sw__row + .cb-sw__row {
  border-top: 1px solid #e2e8f0;
}
.cb-sw:has(.cb-sw__dark:checked) .cb-sw__row + .cb-sw__row {
  border-top-color: #1e293b;
}
.cb-sw__row strong {
  display: block;
  font-size: 15px;
  font-weight: 600;
}
.cb-sw__row small {
  display: block;
  color: #475569;
  font-size: 13px;
}
.cb-sw:has(.cb-sw__dark:checked) .cb-sw__row small {
  color: #94a3b8;
}
.cb-sw__input {
  appearance: none;
  position: relative;
  flex: none;
  box-sizing: border-box;
  width: 48px;
  height: 28px;
  margin: 0;
  border: 2px solid transparent;
  border-radius: 999px;
  background: #64748b;
  cursor: pointer;
  transition: background-color .2s;
}
.cb-sw__input::before {
  content: "";
  position: absolute;
  top: 1px;
  left: 1px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
}
.cb-sw__input:checked {
  background: #0f766e;
}
.cb-sw__input:checked::before {
  transform: translateX(20px);
}
.cb-sw__input:focus-visible {
  outline: 2px solid #0f766e;
  outline-offset: 3px;
}
.cb-sw:has(.cb-sw__dark:checked) .cb-sw__input:focus-visible {
  outline-color: #5eead4;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-sw__input::before {
    transition: transform .2s ease;
  }
}
@media (forced-colors: active) {
  .cb-sw__input { border-color: ButtonText; }
  .cb-sw__input::before { background: ButtonText; }
  .cb-sw__input:checked { background: Highlight; }
  .cb-sw__input:checked::before { background: HighlightText; }
}

Selectable add-on cards with a live total

  • :has(:checked)
  • counter-increment
  • auto-fit grid
Add-ons for your store

Base plan $29 plus add-ons

Pick the add-ons and watch the total.

Each card is a label, so the whole card toggles its checkbox, and :has(:checked) moves the selected styling onto the card. The total is a CSS counter: every checked input increments it by its own --cb-price, and the line below prints the sum with counter().

View code
.cb-plan {
  width: min(100%, 560px);
  counter-reset: cb-total 29;
  color: #1e1b4b;
}
.cb-plan__set {
  margin: 0;
  padding: 0;
  border: 0;
}
.cb-plan__set legend {
  margin-bottom: 12px;
  padding: 0;
  font-size: 17px;
  font-weight: 700;
}
.cb-plan__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
}
.cb-plan__card {
  position: relative;
  display: grid;
  align-content: start;
  gap: 2px;
  min-height: 120px;
  padding: 16px 44px 16px 16px;
  border: 2px solid #d4d4e4;
  border-radius: 14px;
  background: #fff;
  cursor: pointer;
  transition: border-color .15s, background-color .15s;
}
.cb-plan__card:hover {
  border-color: #a5a3c9;
}
.cb-plan__card:has(input:checked) {
  border-color: #6d28d9;
  background: #f5f3ff;
}
.cb-plan__card:has(input:focus-visible) {
  outline: 3px solid #6d28d9;
  outline-offset: 3px;
}
.cb-plan__name {
  font-size: 15px;
  font-weight: 700;
}
.cb-plan__desc {
  color: #4b5563;
  font-size: 13px;
}
.cb-plan__price {
  margin-top: auto;
  padding-top: 10px;
  font-size: 14px;
  font-weight: 600;
}
.cb-plan__card input {
  appearance: none;
  position: absolute;
  top: 14px;
  right: 14px;
  display: grid;
  place-content: center;
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  margin: 0;
  border: 2px solid #6b7280;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}
.cb-plan__card input::before {
  content: "";
  width: 11px;
  height: 11px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-plan__card input:checked {
  border-color: #6d28d9;
  background: #6d28d9;
  counter-increment: cb-total var(--cb-price);
}
.cb-plan__card input:checked::before {
  transform: scale(1);
}
.cb-plan__card input:focus-visible {
  outline: none;
}
.cb-plan__total {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 12px;
  margin: 16px 0 0;
  padding: 14px 16px;
  border-radius: 12px;
  background: #1e1b4b;
  color: #e0e7ff;
  font-size: 14px;
}
.cb-plan__sum {
  color: #fff;
  font-size: 16px;
}
.cb-plan__sum::after {
  content: "$" counter(cb-total) " / mo";
}
@media (forced-colors: active) {
  .cb-plan__card input { border-color: ButtonText; }
  .cb-plan__card input:checked { border-color: Highlight; background: Highlight; }
  .cb-plan__card input::before { background: HighlightText; }
  .cb-plan__card:has(input:checked) { border-color: Highlight; }
}

To-do list that strikes through done items

  • li:has(:checked)
  • text-decoration
  • CSS counters

Launch week

li:has(:checked) dims the task and draws a thick line-through in an accent color. The progress line counts checked and total inputs with two CSS counters; it sits after the list in the DOM so the counters are complete, and flex order moves it to the top visually.

View code
.cb-todo {
  display: flex;
  flex-direction: column;
  width: min(100%, 350px);
  padding: 22px 22px 12px;
  border: 1px solid #e7e2cf;
  border-radius: 6px 22px 22px 6px;
  background: #fffdf4;
  color: #1c1917;
  counter-reset: cb-done cb-all;
  box-shadow: inset 6px 0 0 #b45309;
}
.cb-todo__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.2;
}
.cb-todo__progress {
  order: -1;
  margin: 0;
  color: #57534e;
  font-size: 13px;
}
.cb-todo__title { order: -2; }
.cb-todo__progress::before {
  content: counter(cb-done) " of " counter(cb-all) " done";
}
.cb-todo__list {
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.cb-todo__list li + li {
  border-top: 1px dashed #e7e2cf;
}
.cb-todo__list label {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 10px 0;
  font-size: 15px;
  line-height: 1.4;
  cursor: pointer;
}
.cb-todo__list span {
  text-decoration: line-through 2px transparent;
  transition: color .2s, text-decoration-color .2s;
}
.cb-todo__list input {
  appearance: none;
  display: grid;
  flex: none;
  place-content: center;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2px solid #57534e;
  border-radius: 50% 45% 50% 40%;
  background: #fff;
  counter-increment: cb-all;
  cursor: pointer;
}
.cb-todo__list input::before {
  content: "";
  width: 12px;
  height: 12px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-todo__list input:checked {
  border-color: #b45309;
  background: #b45309;
  counter-increment: cb-all cb-done;
}
.cb-todo__list input:checked::before {
  transform: scale(1);
}
.cb-todo__list input:focus-visible {
  outline: 2px solid #b45309;
  outline-offset: 2px;
}
.cb-todo__list li:has(input:checked) span {
  color: #57534e;
  text-decoration-color: #b45309;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-todo__list input::before {
    transition: transform .15s ease-out;
  }
}
@media (forced-colors: active) {
  .cb-todo__list input { border-color: ButtonText; }
  .cb-todo__list input:checked { border-color: Highlight; background: Highlight; }
  .cb-todo__list input::before { background: HighlightText; }
  .cb-todo__list li:has(input:checked) span { text-decoration-color: CanvasText; }
}

Filter chips that filter a list

  • visually hidden input
  • label:has(:focus-visible)
  • :has() filtering
Cuisine
  • Green curry with tofu 35 min
  • Lemon ricotta gnocchi 40 min
  • Black bean tostadas 25 min
  • Miso salmon rice bowl 30 min
  • Pad see ew 20 min
  • Tomato and fennel risotto 45 min

Pick one or more cuisines.

The input is visually hidden, not display: none, so it stays focusable and the chip shows its focus ring through label:has(:focus-visible). The list filtering is pure CSS: once any chip is checked, rows are hidden unless a checked chip matches their data-cuisine value.

View code
.cb-chips {
  width: min(100%, 420px);
  padding: 20px;
  border-radius: 18px;
  background: #fff;
  color: #1e293b;
  box-shadow: 0 10px 30px rgba(15, 23, 42, .08);
}
.cb-chips__set {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 14px;
  padding: 0;
  border: 0;
}
.cb-chips__set legend {
  float: left;
  width: 100%;
  margin-bottom: 8px;
  padding: 0;
  font-size: 14px;
  font-weight: 700;
}
.cb-chips__chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 36px;
  padding: 0 14px;
  border: 1.5px solid #cbd5e1;
  border-radius: 999px;
  background: #fff;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s, color .15s, border-color .15s;
}
.cb-chips__chip:hover {
  border-color: #64748b;
}
.cb-chips__chip input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.cb-chips__chip span::before {
  content: "";
  display: inline-block;
  width: 0;
  height: 10px;
  margin-right: 0;
  background: currentColor;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  vertical-align: 0;
}
.cb-chips__chip:has(input:checked) {
  border-color: #1e293b;
  background: #1e293b;
  color: #fff;
}
.cb-chips__chip:has(input:checked) span::before {
  width: 11px;
  margin-right: 6px;
}
.cb-chips__chip:has(input:focus-visible) {
  outline: 3px solid #0284c7;
  outline-offset: 2px;
}
.cb-chips__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.cb-chips__list li {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 2px;
  border-top: 1px solid #eef2f7;
  font-size: 15px;
}
.cb-chips__list span {
  flex: none;
  color: #475569;
  font-size: 13px;
}
.cb-chips:has(.cb-chips__set input:checked) .cb-chips__list li {
  display: none;
}
.cb-chips:has(input[value="thai"]:checked) li[data-cuisine="thai"],
.cb-chips:has(input[value="italian"]:checked) li[data-cuisine="italian"],
.cb-chips:has(input[value="mexican"]:checked) li[data-cuisine="mexican"],
.cb-chips:has(input[value="japanese"]:checked) li[data-cuisine="japanese"] {
  display: flex;
}
@media (prefers-reduced-motion: no-preference) {
  .cb-chips__chip span::before {
    transition: width .15s ease, margin-right .15s ease;
  }
}
@media (forced-colors: active) {
  .cb-chips__chip:has(input:checked) { border-color: Highlight; background: Highlight; color: HighlightText; }
}

Checkbox tree with mixed parents

  • nested lists
  • indeterminate parents
  • tree lines

Role permissions Editor

Toggle a parent, then a single child.

Checking a parent checks every child, and each child change walks up the tree to set its parents to checked, unchecked or indeterminate. It is built from nested lists of native checkboxes, which keeps Tab and Space working without the roving focus an ARIA tree widget would need.

View code
.cb-tree {
  width: min(100%, 360px);
  padding: 18px 20px 12px;
  border: 1px solid #1f2937;
  border-radius: 14px;
  background: #111827;
  color: #e2e8f0;
}
.cb-tree__h {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 0 0 8px;
  font-size: 15px;
  font-weight: 700;
}
.cb-tree__h span {
  padding: 2px 10px;
  border-radius: 999px;
  background: #1e293b;
  color: #7dd3fc;
  font-size: 12px;
  font-weight: 600;
}
.cb-tree ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.cb-tree ul ul {
  margin-left: 9px;
  padding-left: 18px;
  border-left: 1px solid #334155;
}
.cb-tree ul ul li {
  position: relative;
}
.cb-tree ul ul li::before {
  content: "";
  position: absolute;
  top: 19px;
  left: -18px;
  width: 12px;
  border-top: 1px solid #334155;
}
.cb-tree label {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 38px;
  font-size: 14px;
  cursor: pointer;
}
.cb-tree label span {
  flex: 1;
}
.cb-tree code {
  color: #94a3b8;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.cb-tree__box {
  appearance: none;
  display: grid;
  flex: none;
  place-content: center;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  margin: 0;
  border: 2px solid #94a3b8;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  transition: background-color .15s, border-color .15s;
}
.cb-tree__box::before {
  content: "";
  width: 10px;
  height: 10px;
  background: #0b0f1a;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-tree__box:checked,
.cb-tree__box:indeterminate {
  border-color: #38bdf8;
  background: #38bdf8;
}
.cb-tree__box:checked::before {
  transform: scale(1);
}
.cb-tree__box:indeterminate::before {
  width: 9px;
  height: 2px;
  clip-path: none;
  transform: scale(1);
}
.cb-tree__box:focus-visible {
  outline: 2px solid #7dd3fc;
  outline-offset: 2px;
}
@media (forced-colors: active) {
  .cb-tree__box { border-color: ButtonText; }
  .cb-tree__box:checked,
  .cb-tree__box:indeterminate { border-color: Highlight; background: Highlight; }
  .cb-tree__box::before { background: HighlightText; }
}

Checkboxes in forms

Required consent, a gated submit button and locked plan features. These demos never submit anywhere; the scripts only stop the submit event and print a status line.

Terms checkbox that gates the submit button

  • :has(:not(:checked))
  • required
  • visual gate

Order summary

Ceramic pour-over set$42.00

Standard shipping$6.00

Total$48.00

Tick the return policy box.

The button looks inactive until the box is ticked, driven by :has() on the form. CSS cannot disable a button, so the required attribute does the real blocking, and browsers without :has() simply show an active button that still refuses to submit.

View code
.cb-gate {
  display: grid;
  width: min(100%, 330px);
  padding: 20px 22px;
  border: 1px solid #e7e5e4;
  border-radius: 4px;
  background: #fafaf9;
  color: #1c1917;
}
.cb-gate__h {
  margin: 0 0 10px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
.cb-gate__line {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 0;
  padding: 6px 0;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.cb-gate__line--total {
  margin-top: 4px;
  padding-top: 10px;
  border-top: 1px solid #d6d3d1;
  font-size: 16px;
  font-weight: 700;
}
.cb-gate__terms {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  margin: 10px 0 12px;
  font-size: 14px;
  cursor: pointer;
}
.cb-gate__box {
  appearance: none;
  display: grid;
  flex: none;
  place-content: center;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2px solid #44403c;
  border-radius: 2px;
  background: #fff;
  cursor: pointer;
}
.cb-gate__box::before {
  content: "";
  width: 12px;
  height: 12px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-gate__box:checked {
  border-color: #1c1917;
  background: #1c1917;
}
.cb-gate__box:checked::before {
  transform: scale(1);
}
.cb-gate__box:focus-visible {
  outline: 2px solid #1c1917;
  outline-offset: 2px;
}
.cb-gate__pay {
  min-height: 48px;
  border: 0;
  border-radius: 4px;
  background: #1c1917;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: .02em;
  cursor: pointer;
  transition: background-color .2s, color .2s;
}
.cb-gate:has(.cb-gate__box:not(:checked)) .cb-gate__pay {
  background: #e7e5e4;
  color: #44403c;
  cursor: not-allowed;
}
.cb-gate__pay:focus-visible {
  outline: 3px solid #1c1917;
  outline-offset: 2px;
}
.cb-gate__status {
  min-height: 20px;
  margin: 10px 0 0;
  color: #166534;
  font-size: 13px;
}
@media (forced-colors: active) {
  .cb-gate__box { border-color: ButtonText; }
  .cb-gate__box:checked { border-color: Highlight; background: Highlight; }
  .cb-gate__box::before { background: HighlightText; }
}

Disabled and locked plan features

  • :disabled
  • hidden input
  • status tags
Team plan features

Checkboxes ignore the readonly attribute, so a locked-on feature is checked and disabled, with a hidden input carrying the value because disabled controls are not submitted. Disabled boxes get a dashed or muted look and a not-allowed cursor, while their labels keep readable text.

View code
.cb-lock {
  width: min(100%, 340px);
  margin: 0;
  padding: 18px 20px 10px;
  border: 1px solid #e2e8f0;
  border-radius: 14px;
  background: #fff;
  color: #0f172a;
}
.cb-lock legend {
  float: left;
  width: 100%;
  margin-bottom: 6px;
  padding: 0;
  font-size: 16px;
  font-weight: 700;
}
.cb-lock__row {
  clear: both;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  font-size: 15px;
  cursor: pointer;
}
.cb-lock__row span {
  flex: 1;
}
.cb-lock__row:has(:disabled) {
  color: #475569;
  cursor: not-allowed;
}
.cb-lock__tag {
  padding: 2px 9px;
  border-radius: 999px;
  background: #f1f5f9;
  color: #334155;
  font-size: 12px;
  font-style: normal;
  font-weight: 600;
}
.cb-lock__tag--up {
  background: #fef3c7;
  color: #78350f;
}
.cb-lock__box {
  appearance: none;
  display: grid;
  flex: none;
  place-content: center;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2px solid #475569;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}
.cb-lock__box::before {
  content: "";
  width: 12px;
  height: 12px;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
  transform: scale(0);
}
.cb-lock__box:checked {
  border-color: #0369a1;
  background: #0369a1;
}
.cb-lock__box:checked::before {
  transform: scale(1);
}
.cb-lock__box:disabled {
  border-style: dashed;
  border-color: #94a3b8;
  background: #f1f5f9;
  cursor: not-allowed;
}
.cb-lock__box:checked:disabled {
  border-style: solid;
  border-color: #94a3b8;
  background: #94a3b8;
}
.cb-lock__box:focus-visible {
  outline: 2px solid #0369a1;
  outline-offset: 2px;
}
@media (forced-colors: active) {
  .cb-lock__box { border-color: ButtonText; }
  .cb-lock__box:checked { border-color: Highlight; background: Highlight; }
  .cb-lock__box::before { background: HighlightText; }
  .cb-lock__box:disabled,
  .cb-lock__box:checked:disabled { border-color: GrayText; background: Canvas; }
  .cb-lock__box:checked:disabled::before { background: GrayText; }
}

Checkbox and label markup that CSS can style

Everything starts with one input and one label, tied together by nesting or by a matching for and id. Get that right before touching any property.

Nesting is the simplest. Put the input inside the label and you don’t need an id at all, and the label text becomes part of the click area. Keeping the two apart gives you more layout freedom, but the id has to be unique on the page, or a click toggles the wrong box.

<label class="field">
  <input type="checkbox" name="updates">
  Email me product updates
  </label>

A bare input gives screen readers nothing to announce and gives sighted users a very small target. Pairing every control with a visible label is the baseline rule behind accessible forms.

Native widgets render with the operating system theme, and MDN Web Docs notes that in this default state they have limited, if any, features that CSS can style. Width and height still work though. MDN’s own accent-color demo sets a checkbox to 40px by 40px and centers it against its label with flex and align-items.

transform: scale() enlarges the painted box but leaves the layout box alone, so neighbors overlap unless you add margin. Sizing with width and height avoids that.

.field {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.field input[type="checkbox"] {
  width: 1.5rem;
  height: 1.5rem;
}

WCAG 2.2 criterion 2.5.8 sets a minimum pointer target of 24 by 24 CSS pixels (W3C). A native checkbox at its default size falls under the user agent control exception. A box you resize yourself does not. At the default 16px root size, 1.5rem equals 24px.

How accent-color recolors a native checkbox

Write accent-color: #0f766e; and the checked state of the native control takes that color. The box keeps its native shape, size and behavior, and the browser picks the checkmark color itself.

Per MDN, supporting browsers apply the property to checkbox, radio, range and progress elements. The value is either auto, which uses the platform accent, or any color. It’s inherited, so one rule on :root reaches every control on the page.

MDN’s browser-compat-data lists the first supporting versions like this:

BrowserFirst versionStatus
Chrome93Supported
Edge93Supported
Firefox92Supported
Safari15.4Partial through 26.1, full from 26.2

The partial entries are about legibility. Safari before 26.2 and Chrome for Android do not maintain minimum contrast for the control, and MDN’s September 2026 revision still labels accent-color as limited availability rather than Baseline.

Most browsers adjust the checkmark so it stays readable. Those two don’t. Contrast between the checked fill and the page background is your job either way, so run your brand color through a color contrast check before you ship it.

Dark surfaces need attention too. CSS-Tricks documents that accent-color respects the color scheme, so pair it with color-scheme and swap the value when your brand color goes dim on dark backgrounds.

:root {
  color-scheme: light dark;
  accent-color: #0f766e;
}

@media (prefers-color-scheme: dark) {
  :root { accent-color: #2dd4bf; }
}

It stops short in a few places. You get no control over box shape, border style or the checkmark glyph, and the unchecked box keeps the native look. Safari 15.4 through 26.1 and Chrome for Android also don’t guarantee a legible checkmark contrast.

If only the brand color matters, stop here. I’d do that for most internal forms. The extra work in the next section buys design freedom, not better behavior.

How to build a custom checkbox with appearance: none

Set appearance: none on the input, then draw your own box and checkmark with a border, a background and a pseudo-element. The real checkbox stays in the DOM, so keyboard behavior, form submission and label clicks keep working.

Build it in this order.

  1. Pair the input with a label, as covered above.
  2. Set appearance: none on the input.
  3. Give the empty box a width, height, border, border-radius and background so it is visible.
  4. Draw the checkmark in ::before and hide it with transform: scale(0).
  5. Reveal it with :checked::before set to scale(1).
  6. Add a :focus-visible outline and a :disabled style.
input[type="checkbox"] {
  appearance: none;
  display: grid;
  place-content: center;
  width: 1.5rem;
  height: 1.5rem;
  border: 2px solid #475569;

  border-radius: 0.25rem;
}

input[type="checkbox"]::before {
  content: "";
  width: 0.75rem;
  height: 0.75rem;
  background: #0f766e;
  transform: scale(0);
  transition: transform 120ms ease-in-out;
}

input[type="checkbox"]:checked::before {
  transform: scale(1);
}

input[type="checkbox"]:focus-visible {
  outline: 2px solid #0f766e;
  outline-offset: 2px;
}

input[type="checkbox"]:disabled {
  border-color: #94a3b8;
}

Removing the native rendering

MDN describes appearance: none as giving the widget a primitive look that CSS can style while native functionality stays intact. It also warns that some widgets disappear completely, and that a hidden checkbox still toggles when its label is clicked.

So a checkbox with appearance none and no other styling is invisible but live. Draw the box in the same rule that removes the native one.

MDN marks unprefixed appearance as Baseline since March 2022 and widely available since September 2024. Keep -webkit-appearance only when your support list reaches back to Safari versions older than 15.4. That’s a cross-browser compatibility call, not a default.

Drawing the checkmark

For a plain tick, a pseudo-element is the simplest route. A shape or border-built tick in ::before needs no extra file and custom properties recolor it, though the geometry takes some hand-tuning.

An SVG background image works too, with background-image: url(checkbox.svg) on the checked state. It stays crisp at any size, but the color is baked into the file. mask-image on a pseudo-element gets around that, since one background-color value tints the icon.

When the checkmark has to match an existing icon set, drawing it as an SVG keeps stroke weight consistent with the rest of the interface.

Which checkbox states need their own CSS?

Checked, disabled, hover, focus-visible and indeterminate all need rules. Skip any of them and the control looks broken in that state.

  • :checked matches a box whose checked state is true, whether it came from the checked attribute or a click.
  • :disabled matches controls with the disabled attribute, which can’t take focus or clicks.
  • :hover matches while a pointer is over the element. Touch screens have no hover, so never rely on it alone.
  • :focus-visible matches when the browser decides a focus ring should show, typically for keyboard focus.
  • :indeterminate matches a checkbox whose indeterminate property is true.

Indeterminate is the odd one, and the one people forget. MDN states it cannot be set with an HTML attribute, only through the indeterminate property in JavaScript, which makes it the one state CSS alone cannot trigger.

document.querySelector("#select-all").indeterminate = true;
input[type="checkbox"]:indeterminate::before {
  width: 0.75rem;
  height: 0.125rem;
  transform: scale(1);
}

The state is visual only. CSS-Tricks points out that it masks the checked value without replacing it, so the form still submits based on checked.

Keep the palette in custom properties so checked, hover and disabled all pull from one set of values. A short transition on the checkmark is the sort of micro-interactions users register without noticing. Wrap it in prefers-reduced-motion: reduce and set transition: none there.

Disabled styling gets more freedom than the rest. W3C’s WCAG 2.2 guidance exempts inactive components from the 1.4.11 contrast rule, so a faded disabled box is acceptable.

Toggle switches and parent styling without JavaScript

Place the input before the element you want to restyle, then select that element with :checked and the adjacent sibling combinator. To restyle a wrapper or a whole card, use :has() instead. Neither needs a script.

Designs vary a lot, but every CSS toggle switch comes down to a track drawn on the input and a thumb that slides when the box is checked.

input.toggle {
  appearance: none;
  position: relative;
  width: 3rem;
  height: 1.75rem;
  border-radius: 999px;
  background: #64748b;
  transition: background-color 150ms;
}

input.toggle::before {
  content: "";
  position: absolute;
  top: 0.125rem;
  left: 0.125rem;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  background: #fff;
  transition: transform 150ms;
}

input.toggle:checked { background: #0f766e; }
input.toggle:checked::before { transform: translateX(1.25rem); }

The thumb travels 1.25rem. That’s a 3rem track, minus the 1.5rem thumb, minus 0.125rem of gap on each side.

Add role="switch" when the control applies a setting immediately, such as dark mode. The ARIA switch role makes screen readers announce on and off instead of checked and unchecked.

Sibling combinators only look forward. A target that sits before the input in the DOM can’t be reached with + or ~, so reorder the markup or reach it with :has().

Reach and support, per MDN’s browser-compat-data and the WebKit blog:

TechniqueReachesSupport
Sibling combinators (+, ~)Later siblings of the inputLong-established
:has()Any element that contains the inputChrome and Edge 105, Firefox 121, Safari 15.4
switch attributeThe input itself, drawn as a native switchSafari 17.4 and later
.option:has(input:checked) {
  border-color: #0f766e;
  background: #f0fdfa;
}

A browser that doesn’t know the switch attribute treats the input as a plain checkbox, according to the WebKit blog. Pair it with appearance: none and you control the look completely.

In Safari the attribute also maps the input to the ARIA switch role, so screen readers announce a switch, per the same WebKit post.

What makes a custom checkbox accessible?

The real input has to stay focusable and in the accessibility tree, every state needs a visible indicator, and those indicators have to hold up under contrast and forced-colors checks.

A few numbers and behaviors are worth testing against.

  • The box border and the checked indicator need 3:1 against adjacent colors (W3C, WCAG 2.2 criterion 1.4.11).
  • Every keyboard-operable control needs a visible focus indicator at Level AA (W3C, criterion 2.4.7).
  • Tab moves focus to the checkbox and Space toggles it (WAI-ARIA Authoring Practices Guide).

W3C pairs 2.4.7 with 1.4.11, so the 3:1 rule covers your focus ring too.

One exception explains why custom builds carry more risk. Criterion 1.4.11 exempts components whose appearance is determined by the user agent and not modified by the author. Restyle the box with appearance: none and that exemption ends.

You can leave the input visible with appearance: none, as in the build above, or hide it visually and draw the box on the label.

Avoid display: none for the hidden version. The ally.js documentation lists it as removing an element from both the accessibility tree and keyboard navigation, so the custom box could never take focus.

.sr-input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.sr-input:focus-visible + label::before {
  outline: 2px solid #0f766e;
  outline-offset: 2px;
}

Replacing the input with a div that carries role="checkbox" is the obvious route and the wrong one. The APG’s div-based example needs tabindex, a role and an aria-checked value kept in sync with the visuals, all things the native input already handles.

Forced-colors mode swaps your palette for the user’s. MDN documents box-shadow and text-shadow forced to none, and background-image forced to none for values that are not url-based.

Url-based images stay. Early drafts of the spec reverted background-image on user input controls, but the CSS Working Group dropped that special case in 2020, and the current css-color-adjust spec only removes images without a url(). A url-based SVG checkmark can still clash with the forced palette, so test it before you rely on it.

Draw the mark with a system color keyword instead. A system color in your CSS wins over the forced value.

@media (forced-colors: active) {
  input[type="checkbox"] { border-color: ButtonText; }
  input[type="checkbox"]::before { background: CanvasText; }
  input[type="checkbox"]:focus-visible { outline-color: Highlight; }
}

Before release, run the control through a web accessibility checklist. Then use it with the keyboard alone, and with NVDA or VoiceOver.

Native or custom, and which method fits your project

Pick the cheapest method that meets the brief. accent-color covers it when only the brand color matters. Reach for appearance: none when shape or icon is a requirement, and use a hidden input with a drawn label only when the layout can’t be built on the input itself.

MethodControlEffortMain risk
accent-colorChecked color onlyLowUnchecked box stays native
appearance: noneShape, border, icon, statesMediumContrast and focus ring become your job
Hidden input plus drawn labelFull layout freedomHighFocus and forced colors need manual repair

A settings page with ten toggles rarely needs more than accent-color. A survey with icon checkboxes calls for appearance none. Animated option tiles where the whole card is the target are the case for a hidden input with a label.

Framework shortcuts

Tailwind CSS exposes accent-color as accent-* utilities, such as accent-pink-500. Its docs add a caveat: the opacity modifier, as in accent-purple-500/25, has limited browser support and works only in Firefox.

For custom boxes, the Tailwind forms plugin, @tailwindcss/forms, resets form controls so utilities can style them. Per its README, a text color utility such as text-pink-500 changes the color of a checkbox.

Bootstrap’s checkbox markup takes the class route. form-check goes on the wrapper, form-check-input on the input, form-check-label on the label, and form-switch turns it into a toggle. The docs also show an indeterminate example, which still needs the JavaScript property.

Where CSS checkboxes break

They break where styling ends and behavior begins, and in a few support gaps. Each of these has a workaround, but none of them goes away.

  • CSS reads the checked state and never writes it. A parent box that checks its children runs on script, and the APG models the tri-state case with aria-checked set to mixed.
  • A browser below the :has() thresholds above drops the entire rule, so keep a working baseline and layer the parent styling on top.
  • MDN notes that some browsers return a fixed value for auto to limit fingerprinting, so don’t design around matching the operating system accent.
  • MDN lists a base value for appearance in the spec but says no browser supports it yet (October 2026 revision). A checkbox is either native or fully yours, with no halfway option.
  • The @tailwindcss/forms README warns that its default approach may be too heavy-handed for an existing project, and its class strategy limits the styling to elements that carry the generated form-* classes, such as form-checkbox.
@supports selector(:has(*)) {
  .option:has(input:checked) {
    background: #f0fdfa;
  }
}

Skip customization altogether when the form is internal, when the brand color is the only requirement, or when nobody will own cross-browser testing. accent-color covers all of those.

CSS Checkboxes FAQ

How do you make a checkbox round?

Remove the native rendering with appearance: none, then set border-radius: 50% on the input. Give the ::before checkmark the same radius so the fill follows the curve.

How do you style a group of checkboxes?

Wrap them in a fieldset with a legend, which names the group for screen readers. Give every input one shared class, lay them out with flex or grid and a gap, and drive colors from one set of custom properties.

Can CSS check a checkbox by default?

No. The HTML checked attribute sets the default state, and CSS only reads it through :checked.

A pre-checked box has to come from the markup or from script.

Is styling a checkbox different from styling a radio button?

Mostly no. The same appearance, :checked and accent-color rules apply to both, and a round shape is the usual radio default.

The difference is behavior. Radios that share a name attribute allow one selection, and clicking a checked radio does not uncheck it.

How to Test a Custom Checkbox Before Release

Start with the keyboard, move to forced-colors emulation, and finish with a screen reader. That order runs from cheapest to most expensive.

  • Keyboard only, with Tab to focus and Space to toggle
  • Forced-colors emulation in the DevTools Rendering panel
  • NVDA on Windows, VoiceOver on macOS

Keyboard failures block operation and take two minutes to find. Screen readers go last, because they confirm names and states only after focus and visibility work.

Then check the support floor. Chrome 93 through 104 and Firefox 92 through 120 run accent-color without :has(), so parent highlighting needs the sibling fallback there.

The same passes apply to every other control in a form, which makes the guide to CSS forms the next stop.