How to Create a Set of Custom Checkboxes

Native checkboxes do not always fit your brand, and browser defaults jump around between platforms. In this project you will build a small, themeable set of custom checkboxes that look sharp, animate cleanly, and remain fully accessible. By the end you will have square, rounded, and circular variants that you can drop into forms without extra JavaScript.

Why Custom Checkboxes Matter

Form controls carry weight in a design system. A unified checkbox set keeps your UI consistent without shipping icon fonts or extra images. A CSS approach cuts requests, avoids raster artifacts on high-DPI screens, and lets you theme through variables. The native input still does the work, so forms submit as expected and keyboard users keep a familiar experience.

Prerequisites

You only need a few fundamentals. The technique relies on a hidden native input, a paired label, and pseudo-elements to render the box and checkmark.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup keeps the native input for semantics and uses a sibling label for the visuals. The label holds the visible box and text. The input remains focusable and toggles the checked state, which we target in CSS using the adjacent sibling selector.

<!-- HTML -->
<div class="demo">
  <fieldset class="group">
    <legend>Newsletter preferences</legend>

    <div class="check">
      <input class="cb-input" type="checkbox" id="cb-news">
      <label class="cb cb--square" for="cb-news">
        <span class="cb__label">Receive product updates</span>
      </label>
    </div>

    <div class="check">
      <input class="cb-input" type="checkbox" id="cb-tips" checked>
      <label class="cb cb--rounded" for="cb-tips">
        <span class="cb__label">Weekly tips</span>
      </label>
    </div>

    <div class="check">
      <input class="cb-input" type="checkbox" id="cb-beta" disabled>
      <label class="cb cb--circle" for="cb-beta">
        <span class="cb__label">Join beta program</span>
      </label>
    </div>

  </fieldset>
</div>

Step 2: The Basic CSS & Styling

Start with variables for color and size, a clean body style, and a visually-hidden utility to remove the default checkbox while keeping it accessible. The label becomes a flex container so the square, rounded, or circular box sits next to the text with a consistent gap.

/* CSS */
:root {
  --cb-size: 1.25rem;
  --cb-border: 2px;
  --cb-radius: 0.375rem;

  --cb-bg: #ffffff;
  --cb-text: #0f172a;
  --cb-border-color: #9aa4af;

  --cb-checked-bg: #2f6feb;
  --cb-checked-border: #2f6feb;
  --cb-tick-color: #ffffff;

  --cb-hover-border: #6b7280;
  --cb-disabled: #9aa4af;

  --cb-focus-shadow: 0 0 0 3px color-mix(in srgb, var(--cb-checked-bg) 35%, transparent);
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
  color: var(--cb-text);
  background: #f8fafc;
  padding: 2rem;
  line-height: 1.5;
}

.demo {
  max-width: 640px;
  margin: 0 auto;
}

.group {
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  padding: 1rem 1.25rem 1.25rem;
  background: #ffffff;
}

.group > legend {
  padding: 0 0.4rem;
  font-weight: 600;
}

.check {
  display: flex;
  align-items: center;
  margin-top: 0.75rem;
}

/* Visually hidden, still focusable and accessible */
.cb-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Advanced Tip: Theme with variables. You can define color tokens per brand or per dark mode and swap them using a class on the root. This keeps your checkbox variants in sync with the rest of your system.

Step 3: Building the Base Checkbox

The label renders both the box and the tick. The box comes from ::before; the tick comes from ::after using two borders rotated 45 degrees. The input state toggles the visuals using the adjacent sibling selector.

/* CSS */
.cb {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.625rem;
  min-height: var(--cb-size);
  cursor: pointer;
  user-select: none;
}

/* The visible box */
.cb::before {
  content: "";
  width: var(--cb-size);
  height: var(--cb-size);
  background: var(--cb-bg);
  border: var(--cb-border) solid var(--cb-border-color);
  border-radius: var(--cb-radius); /* default shape for cb--square */
  box-shadow: 0 0 0 0 transparent;
  transition: background .2s ease, border-color .2s ease, box-shadow .2s ease, transform .2s ease;
  flex: 0 0 var(--cb-size);
}

/* The checkmark */
.cb::after {
  content: "";
  position: absolute;
  /* position relative to the label; the transforms below place it inside the box */
  width: calc(var(--cb-size) * 0.35);
  height: calc(var(--cb-size) * 0.6);
  border-right: 2px solid var(--cb-tick-color);
  border-bottom: 2px solid var(--cb-tick-color);
  transform: translate(calc(var(--cb-size) * 0.32), calc(var(--cb-size) * -0.05)) rotate(45deg) scale(0);
  transform-origin: bottom left;
  transition: transform .18s ease-out;
}

/* Hover and active feedback */
.cb:hover::before {
  border-color: var(--cb-hover-border);
}
.cb:active::before {
  transform: scale(0.96);
}

/* Checked state styles the box and reveals the tick */
.cb-input:checked + .cb::before {
  background: var(--cb-checked-bg);
  border-color: var(--cb-checked-border);
}
.cb-input:checked + .cb::after {
  transform: translate(calc(var(--cb-size) * 0.32), calc(var(--cb-size) * -0.05)) rotate(45deg) scale(1);
}

/* Focus ring lands on the visual box via sibling selector */
.cb-input:focus-visible + .cb::before {
  box-shadow: var(--cb-focus-shadow);
}

/* Disabled state */
.cb-input:disabled + .cb {
  cursor: not-allowed;
  opacity: 0.6;
}
.cb-input:disabled + .cb:hover::before {
  border-color: var(--cb-border-color);
}

/* Label text */
.cb__label {
  display: inline-block;
}

How This Works (Code Breakdown)

The label uses inline-flex so the pseudo-element box sits beside the text with a fixed size. The ::before block has a solid border and a white background, which equals the visual control. This mirrors the look of a native box, while the input continues to manage state and accessibility.

The checkmark relies on a classic trick: two borders forming an L, rotated 45 degrees. Using only borders avoids extra elements and scales cleanly. The transform uses scale(0) when unchecked and scale(1) when checked for a clean pop. Because transforms run on the compositor, the tick appears smooth and avoids layout work.

The adjacent sibling selectors wire it together. When the input is checked, .cb-input:checked + .cb::before applies the colored background and border, and .cb-input:checked + .cb::after scales the tick to full size. The focus-visible style sends a ring to the visual box to reflect keyboard focus. A subtle active scale gives click feedback without wobble.

For the shape of the box, you are building a square using border and fixed width and height. If you want a refresher on the fundamentals of shape construction, see how to make a square with CSS. The same ideas carry over here. The tick’s angled stroke feels like a bent arrow. The technique resembles a chevron, so the page on how to make a chevron-right with CSS maps closely to the rotated checkmark you see here.

Step 4: Building the Variants

A set needs more than one look. You can keep the same structure and switch only a few variables and radii. Rounded corners soften the control without changing dimensions. A circular variant swaps the box for a perfect disc that still toggles like a checkbox.

/* CSS */
/* Square: already covered by default .cb::before radius via --cb-radius */

/* Rounded: a bit softer */
.cb.cb--rounded::before {
  border-radius: calc(var(--cb-radius) + 0.25rem);
}

/* Circle: keep the same size, round to a disc */
.cb.cb--circle::before {
  border-radius: 999px;
}

/* Optional theming hooks per variant */
.cb.cb--rounded {
  --cb-checked-bg: #16a34a;   /* green */
  --cb-checked-border: #16a34a;
}
.cb.cb--circle {
  --cb-checked-bg: #f97316;   /* orange */
  --cb-checked-border: #f97316;
}

/* Size modifiers if you need a compact or large set */
.cb.cb--sm { --cb-size: 1rem; }
.cb.cb--lg { --cb-size: 1.5rem; }

How This Works (Code Breakdown)

The rounded and circular designs only change border-radius on the ::before box, which keeps the tick math intact. Using class modifiers at the label level means you do not fork the structure or duplicate HTML. You can even apply a class on a parent component to theme an entire section through CSS variables.

The circular option is just a disc with border-radius: 999px. If you want a quick visual primer on curving a box into a disc, revisit how to make a circle with CSS. The tick stays centered because its transform is derived from –cb-size, so every variant scales predictably.

The green and orange samples demonstrate per-variant overrides for the checked state. This approach lets you publish a palette of checkbox styles without touching the core. The optional size modifiers expose a compact and large size with no extra math. Because everything references –cb-size, borders, tick dimensions, and spacing remain in proportion.

Advanced Techniques: Adding Animations & Hover Effects

A small dose of motion makes the control feel responsive. The tick can pop in with a brief overshoot. The box can glow on hover and focus. Keep the durations short so the control does not feel sluggish.

/* CSS */
/* Tick pop with a tiny overshoot */
@keyframes tick-pop {
  0%   { transform: translate(calc(var(--cb-size) * 0.32), calc(var(--cb-size) * -0.05)) rotate(45deg) scale(0); }
  70%  { transform: translate(calc(var(--cb-size) * 0.32), calc(var(--cb-size) * -0.05)) rotate(45deg) scale(1.12); }
  100% { transform: translate(calc(var(--cb-size) * 0.32), calc(var(--cb-size) * -0.05)) rotate(45deg) scale(1); }
}

.cb-input:checked + .cb::after {
  animation: tick-pop .18s ease-out forwards;
}

/* Subtle hover border and focus glow already present; add a gentle box pulse on check */
@keyframes box-pulse {
  0%   { box-shadow: var(--cb-focus-shadow); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.cb-input:checked + .cb::before {
  animation: box-pulse .25s ease-out;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .cb::before,
  .cb::after {
    transition: none !important;
    animation: none !important;
  }
}

The tick-pop animation overshoots by a small amount, then settles. Because the transform coordinates embed the translation and rotation in each keyframe, the tick stays in place while scaling. The box-pulse gives a brief focus-like glow that fades fast, which reads as feedback without drawing too much attention.

Accessibility & Performance

Custom visuals should not degrade the user experience. The input remains in the DOM, the label ties clicks to the input, and focus styles land on the visual box. Keep contrast and target size in mind so the control reads well and can be tapped easily on touch devices.

Accessibility

The for attribute connects the label and input, which gives a large clickable area that includes the text. The input stays focusable, and :focus-visible sends a ring to ::before so keyboard users can see where they are. The default tick is white on a colored background to meet contrast expectations. If your brand uses lighter colors, darken the tick to meet contrast in high-contrast modes.

Keep the touch target near 44 by 44 CSS pixels. If your line-height is small, add padding to the label to increase the hit area. The disabled state dims opacity and locks the pointer cursor to signal that the control does not accept input. Because the input remains native, screen readers announce it as a checkbox with the correct state and label text.

Some projects can use accent-color for a quick theme on native checkboxes. Here the visuals come from CSS around the native input, which gives you full control over corners, hover, and the tick shape. You can still provide a basic fallback with accent-color if you switch to non-custom styling for older browsers.

Performance

This build ships no images and no icon fonts. The tick uses borders and transforms, which are cheap to animate. The short transitions and keyframes avoid layout thrash. Keep box-shadow animations brief. A long-running shadow on many checkboxes can cost paint time, so prefer transform-based motion for most effects.

Ship a Checkbox Set You Can Trust

You built a square, a rounded, and a circular checkbox, each with a clean tick, hover feedback, focus ring, and disabled state, all powered by a native input. The CSS stays readable and themeable through variables, which makes brand swaps easy.

Extend this pattern for sizes and color states across your form library. Now you have the tools to design and ship your own checkbox set without pulling in external assets or extra scripts.

Leave a Comment