Form
Checkbox
A checkbox built on the native one, with an indeterminate state and real form validation.
- Element
<slab-checkbox>- Built on
<input type=checkbox>- As Markdown
- checkbox.md
Example
Here's a styled version, <slab-styled-checkbox>. It's not
in the package — it's an example of what you can build with the
headless element. Copy it and make it yours at the
bottom of this page. Switch to "As it ships" to see the same markup
with the headless element and no CSS at all. That's your starting
point.
<div class="stack">
<slab-styled-checkbox label="Call 811 before digging" checked></slab-styled-checkbox>
<slab-styled-checkbox label="Order the pump truck"></slab-styled-checkbox>
<slab-styled-checkbox label="Some of the crew is confirmed" indeterminate></slab-styled-checkbox>
</div>
<div class="stack">
<slab-checkbox label="Call 811 before digging" checked></slab-checkbox>
<slab-checkbox label="Order the pump truck"></slab-checkbox>
<slab-checkbox label="Some of the crew is confirmed" indeterminate></slab-checkbox>
</div>
Usage
pnpm add slab-elements lit
<script type="module">
import 'slab-elements/checkbox.js';
</script>
<slab-checkbox label="I agree to the terms" name="terms" required></slab-checkbox>
Keyboard
| Key | What it does |
|---|---|
| Space | Checks or unchecks it. |
Behavior
A checkbox with no visual styles, around a native one, so it is checked, focused, and read the way one is.
The label and the checkbox are both in the shadow root, so a plain for reference names it without crossing out of one. The host delegates focus, so clicking anywhere on it, or a <label for> pointing at it, focuses the checkbox.
Style it through its parts and through :state(checked), :state(disabled), :state(error), :state(indeterminate), and :state(required). Nothing is drawn for a check or a dash: those are marks a design makes, from ::part(checkbox). Where the label sits relative to the checkbox is layout, so it's left to the styles around it.
API
<slab-checkbox>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
||
checked
|
boolean |
false |
Like a native checkbox, the attribute is the default state and the property is the current one, so the attribute doesn't change as the checkbox is checked. Style :state(checked) rather than [checked]. |
disabled
|
boolean |
false |
|
hide-label
|
boolean |
false |
|
indeterminate
|
boolean |
false |
|
name
|
string |
'' |
|
required
|
boolean |
false |
|
value
|
string |
'' |
An empty value is submitted as nothing, and counts for nothing in a Checkbox Group's value. |
version
|
string |
| Name | Type | Description |
|---|---|---|
form
Read-only
|
HTMLFormElement | null |
|
validity
Read-only
|
ValidityState |
| Signature | Description |
|---|---|
checkValidity(): boolean
|
|
click(): void
|
|
focus(options?: FocusOptions): void
|
|
reportValidity(): boolean
|
|
resetValidityFeedback(): void
|
Clears the validity feedback without changing validity. |
setCustomValidity(message: string): void
|
|
setValidity(flags?: ValidityStateFlags, message?: string): void
|
| Name | Type | Description |
|---|---|---|
input
|
Event |
|
change
|
Event |
| Name | Type | Description |
|---|---|---|
description
|
Element | string |
| Name | Description |
|---|---|
checkbox
|
Where a design draws its check or dash |
description
|
Text about the checkbox, and its validity message when it has one |
input
|
|
label
|
The label |
validity-message
|
Copy and own it
<slab-styled-checkbox> isn't in the package. It's an example —
copy it into your project and change whatever you want. Under the
hood, it's just the headless element, subclassed, with
styles set. That's it. Keyboard, focus, ARIA, forms, and
events all come from the headless element, and the styles only reach
in through ::part() and :state(), same as
yours would.
To use it, copy these files, swap the tag in
@customElement() for one of your own, and import it.
tokens.styles.ts holds the colors and fonts every styled
example shares, so you only need it once.
checkbox.ts
import { customElement } from 'lit/decorators.js';
import Checkbox from 'slab-elements/checkbox.js';
import styles from './checkbox.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-checkbox': StyledCheckbox;
}
}
/**
* Checkbox, styled: a square box that fills with ember when checked, with a
* check or a dash drawn on it in paper.
*/
@customElement('slab-styled-checkbox')
export default class StyledCheckbox extends Checkbox {
static override styles = [tokens, styles];
}
checkbox.styles.ts
import { css } from 'lit';
export default css`
:host {
align-items: center;
color: var(--ink);
column-gap: 0.75rem;
display: grid;
font-family: var(--font-body);
grid-template-columns: auto 1fr;
}
:host(:state(disabled)) {
color: var(--ash);
}
[part~='checkbox'] {
display: grid;
position: relative;
}
[part~='input'] {
appearance: none;
background-color: var(--paper);
block-size: 1.375rem;
border: var(--border);
cursor: pointer;
inline-size: 1.375rem;
margin: 0;
transition:
background-color 100ms,
box-shadow 100ms;
&:hover {
box-shadow: 2px 2px 0 var(--ink);
}
&:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
&:disabled {
border-style: dashed;
box-shadow: none;
cursor: not-allowed;
opacity: 0.5;
}
}
:host(:state(checked)) [part~='input'],
:host(:state(indeterminate)) [part~='input'] {
background-color: var(--ember);
}
:host(:state(error)) [part~='input'] {
border-color: var(--ember-deep);
box-shadow: 2px 2px 0 var(--ember-deep);
}
/*
The mark is a shape cut from paper with a mask, so it takes the theme's
color. It's drawn on rather than shown: a clip slides off it from left to
right, the way Glide Core's stroke draws on. Unchecking takes it away at
once. It doesn't take clicks, so the input under it still does.
*/
[part~='checkbox']::after {
background-color: var(--paper);
clip-path: inset(0 100% 0 0);
content: '';
inset: 2px;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.25l3.25 3.25L13 4.5' fill='none' stroke='black' stroke-width='2.5'/%3E%3C/svg%3E")
center / contain no-repeat;
pointer-events: none;
position: absolute;
}
:host(:state(checked)) [part~='checkbox']::after,
:host(:state(indeterminate)) [part~='checkbox']::after {
clip-path: inset(0);
transition: clip-path 300ms cubic-bezier(0.32, 0, 0.67, 0);
}
:host(:state(indeterminate)) [part~='checkbox']::after {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8h9' fill='none' stroke='black' stroke-width='2.5'/%3E%3C/svg%3E");
}
[part~='label'] {
cursor: pointer;
}
:host(:state(required)) [part~='label']::after {
color: var(--ember-deep);
content: ' *' / '';
}
[part~='description'] {
color: var(--ash);
font-size: 0.875rem;
grid-column: 2;
}
[part~='validity-message'] {
color: var(--ember-deep);
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
[part~='input'],
:host(:state(checked)) [part~='checkbox']::after,
:host(:state(indeterminate)) [part~='checkbox']::after {
transition: none;
}
}
`;
tokens.styles.ts
import { css } from 'lit';
/*
The styled components' palette and type, taken from the logo: ink for the
courses, ember for the base, paper behind them.
Each is a short alias for a "--slab-styled-*" property, with the default as
its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
which every shadow root inherits. Were the defaults declared under those
names here instead, each host's own declaration would shadow the page's.
Each default is "light-dark()", so the components follow the page's
"color-scheme": "dark" for dark, "light" for light, and "light dark" to
follow the operating system. Ink and paper trade places in the dark, so
ink is always the text and paper always what's behind it. Contrast is given
as light, then dark.
*/
export default css`
:host {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
`;