Accessible Checkbox and Radio Button

To make inputs accessible, we must leave native <input> elements visible to robots, meaning no display: hidden;.

Reset appearance if <input>s with appearance: none;. This allows us to not put unneccessary <span> inside <label>s.

For more complex styling, e.g. with SVGs, put them after inputs and use input:checked + svg selectors.

<label class="checkbox">
    <input type="checkbox" />
    <span>Checkbox</span>
</label>

<label class="radio">
    <input type="radio" name="radio" value="1" />
    <span>Radio 1</span>
</label>

<label class="radio">
    <input type="radio" name="radio" value="2" />
    <span>Radio 2</span>
</label>
:root {
    --input-height-regular: 2rem;

    --lh-sm: 1.25;

    --space-sm: 0.5rem;
    --space-md: 1.5rem;

    --color-box: 190 190 190;
    --color-accent: 33 112 255;
}

.checkbox, .radio {
    position: relative;

    min-height: var(--input-height-regular);
    padding-inline: var(--space-md);

    display: grid;
    grid-template-columns: max-content auto;
    grid-column-gap: var(--space-sm);
    align-items: center;

    text-align: start;

    cursor: pointer;

    & input {
        appearance: none;

        display: inline-grid;
        place-items: center;

        --_input-color: var(--color-box);
        --_input-opacity: .5;
        --_input-thickness: 1px;

        width: calc(1em * var(--lh-sm));
        height: auto;
        aspect-ratio: 1;

        border: 0.08em solid rgb(var(--_input-color) / var(--_input-opacity));

        transition: border-width 10ms linear, border-color 150ms ease;
    }

    & input:checked {
        --_input-color: var(--color-accent);
        --_input-opacity: 1;
        --_input-thickness: 2px;
    }

    &:hover input {
        --_input-color: var(--color-accent);
    }
}

.radio:has(input:checked) {
    cursor: default;
    pointer-events: none;
}

.radio {
    & input {
        border-radius: 9999px;

        background-image: radial-gradient(circle at center, rgb(var(--_input-color) / var(--_input-opacity)) 37.5%, transparent 37.5%);
        background-repeat: no-repeat;
        background-position: center;
    }
}

.checkbox {
    & input {
        &::before {
            content: '';
            display: block;
            width: 30%;
            height: 0;
            border-right: 0 solid rgb(var(--_input-color) / var(--_input-opacity));
            border-bottom: 0 solid rgb(var(--_input-color) / var(--_input-opacity));
            transform: translateY(-0.08em) rotate(45deg);

            transition: height 100ms ease;
        }

        &:checked::before {
            height: 66%;
            border-right-width: 0.1em;
            border-bottom-width: 0.1em;
        }
    }
}

Fake Inputs

There is also "fake" versions of those inputs: they are not interactive and there for visual appearance only. One of use cases for them is putting them inside button, that submits to theme change route withou JavaScript, meaning the page will reload.

Just extend basic styles with some additional selectors.

<span class="checkbox-fake">
    <span class="tick" aria-hidden="true"></span>
    <span>Fake checkbox</span>
</span>

<span class="radio-fake">
    <span class="tick" aria-hidden="true"></span>
    <span>Fake radio</span>
</span>
:root {
    --input-height-regular: 2rem;

    --lh-sm: 1.25;

    --space-sm: 0.5rem;
    --space-md: 1.5rem;

    --color-box: 190 190 190;
    --color-accent: 33 112 255;
}

.checkbox,
.radio {
    &, &-fake {
        position: relative;

        min-height: var(--input-height-regular);
        padding-inline: var(--space-md);

        display: grid;
        grid-template-columns: max-content auto;
        grid-column-gap: var(--space-sm);
        align-items: center;

        cursor: pointer;
    }

    & input,
    &-fake .tick {
        appearance: none;

        display: inline-grid;
        place-items: center;

        --_input-color: var(--color-box);
        --_input-opacity: .5;
        --_input-thickness: 1px;

        width: calc(1em * var(--lh-sm));
        height: auto;
        aspect-ratio: 1;

        border: 0.08em solid rgb(var(--_input-color) / var(--_input-opacity));

        transition: border-width 10ms linear, border-color 150ms ease;
    }

    & input:checked,
    &-fake[data-checked] .tick {
        --_input-color: var(--color-accent);
        --_input-opacity: 1;
        --_input-thickness: 2px;
    }

    &:hover input,
    &-fake:hover .tick {
        --_input-color: var(--color-accent);
    }
}

.radio:has(input:checked),
.radio-fake[data-checked] {
    cursor: default;
    pointer-events: none;
}

.radio {
    & input,
    &-fake .tick {
        border-radius: 9999px;

        background-image: radial-gradient(circle at center, rgb(var(--_input-color) / var(--_input-opacity)) 37.5%, transparent 37.5%);
        background-repeat: no-repeat;
        background-position: center;
    }
}

.checkbox {
    & input,
    &-fake .tick {
        &::before {
            content: '';
            display: block;
            width: 30%;
            height: 0;
            border-right: 0 solid rgb(var(--_input-color) / var(--_input-opacity));
            border-bottom: 0 solid rgb(var(--_input-color) / var(--_input-opacity));
            transform: translateY(-0.08em) rotate(45deg);

            transition: height 100ms ease;
        }
    }

    & input:checked,
    &-fake[data-checked] .tick {
        &::before {
            height: 66%;
            border-right-width: 0.1em;
            border-bottom-width: 0.1em;
        }
    }
}
$ cd ..