Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.
Components / UI
Check Draw
A checkbox whose tick is drawn. A label wraps a native input with appearance: none and an SVG beside it: the box fills on the fast token, then the tick (a path with pathLength 1) runs its dash offset from 1 to 0 on the preset curve. The indeterminate state draws a bar instead. The value submits, required and disabled work, the keyboard is the browser's. Zero JavaScript.
Live demo
a tick that draws · one indeterminate
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 6.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import CheckDraw from '@moonarc/core/CheckDraw';
---
<CheckDraw name="terms" label="I agree to the terms" required />
<CheckDraw name="news" label="Send me the newsletter" checked />
<!-- indeterminate is a DOM property, set from your script -->
<CheckDraw id="all" name="all" label="Select all" />
<script>document.getElementById('all').indeterminate = true</script>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/check-draw.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* CheckDraw — a checkbox whose tick is drawn, zero JS. A <label> wraps a
* native `<input type="checkbox">` (`appearance: none`, still focusable,
* still submitted) and an SVG beside it: the tick is a path with
* `pathLength="1"` whose dash offset runs 1 → 0 on the preset curve when
* the input is `:checked`; `:indeterminate` (a DOM property your script
* sets) draws a short bar instead. The box fills on the fast token first,
* so the stroke lands on ink. Radios are not this component.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'size'> {
name?: string;
value?: string;
checked?: boolean;
/** Visible label. Without it, pass aria-label. */
label?: string;
/** Box size, any CSS length. */
size?: string;
/** Box colour when checked; default the ink token. */
color?: string;
}
const { name, value = 'on', checked = false, label, size = '1.25rem', color, class: className, style, ...rest } = Astro.props;
const vars = [`--ma-checkdraw-size:${size}`, color && `--ma-checkdraw-on:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<label class:list={['ma-checkdraw', className]} style={vars}>
<span class="ma-checkdraw__box">
<input type="checkbox" class="ma-checkdraw__input" name={name} value={value} checked={checked} {...rest} />
<svg class="ma-checkdraw__mark" viewBox="0 0 24 24" aria-hidden="true">
<path class="ma-checkdraw__tick" d="M5 12.5l4.5 4.5L19 7" pathLength="1" />
<path class="ma-checkdraw__bar" d="M6 12h12" pathLength="1" />
</svg>
</span>
{label && <span class="ma-checkdraw__label">{label}</span>}
</label>
<style is:global>
@layer components {
:where(.ma-checkdraw) {
display: inline-flex;
align-items: center;
gap: 0.6em;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
:where(.ma-checkdraw__box) {
position: relative;
flex: none;
display: grid;
inline-size: var(--ma-checkdraw-size, 1.25rem);
block-size: var(--ma-checkdraw-size, 1.25rem);
}
:where(.ma-checkdraw__input) {
/* the box: the input itself, so focus, the value and the keyboard are native */
appearance: none;
-webkit-appearance: none;
position: relative;
grid-area: 1 / 1;
inline-size: 100%;
block-size: 100%;
margin: 0;
/* --ma-checkdraw-edge: a section that knows its ground sets a border measured on it (HeroWaitlist, 7G) */
border: 1.5px solid var(--ma-checkdraw-edge, color-mix(in srgb, var(--ma-ink) 45%, transparent));
border-radius: 0.3em;
background: transparent;
cursor: pointer;
font: inherit;
transition:
background-color var(--ma-duration-fast) var(--ma-ease-out),
border-color var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-checkdraw__input)::after {
content: '';
position: absolute;
inset: min(0px, calc((var(--ma-checkdraw-size, 1.25rem) - 26px) / 2));
}
:where(.ma-checkdraw__input:checked),
:where(.ma-checkdraw__input:indeterminate) {
background: var(--ma-checkdraw-on, var(--ma-ink));
border-color: var(--ma-checkdraw-on, var(--ma-ink));
}
:where(.ma-checkdraw__mark) {
grid-area: 1 / 1;
inline-size: 100%;
block-size: 100%;
padding: 12%;
pointer-events: none;
fill: none;
stroke: var(--ma-panel);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
:where(.ma-checkdraw__tick),
:where(.ma-checkdraw__bar) {
/* pathLength 1: the dash is the whole stroke, offset 1 hides it, 0 shows it */
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--ma-duration-fast) var(--ma-ease-in);
}
:where(.ma-checkdraw__input:checked ~ .ma-checkdraw__mark .ma-checkdraw__tick),
:where(.ma-checkdraw__input:indeterminate ~ .ma-checkdraw__mark .ma-checkdraw__bar) {
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--ma-duration) var(--ma-ease);
/* the stroke waits for the box to fill, so it is drawn on ink, not on nothing */
transition-delay: calc(var(--ma-duration-fast) / 2);
}
:where(.ma-checkdraw__input:focus-visible) {
outline: 2px solid var(--ma-checkdraw-on, var(--ma-ink));
outline-offset: 3px;
}
:where(.ma-checkdraw__input:disabled) {
cursor: not-allowed;
opacity: 0.45;
transition: none;
}
:where(.ma-checkdraw__input:disabled ~ .ma-checkdraw__mark *) {
transition: none;
}
:where(.ma-checkdraw:has(.ma-checkdraw__input:disabled)) {
cursor: not-allowed;
}
:where(.ma-checkdraw:has(.ma-checkdraw__input:disabled) .ma-checkdraw__label) {
opacity: 0.6;
}
:where(.ma-checkdraw__label) {
line-height: 1.2;
user-select: none;
}
@media (forced-colors: active) {
:where(.ma-checkdraw__input) {
border-color: ButtonText;
background: Canvas;
}
:where(.ma-checkdraw__input:checked),
:where(.ma-checkdraw__input:indeterminate) {
background: Highlight;
border-color: Highlight;
}
:where(.ma-checkdraw__mark) {
stroke: HighlightText;
}
:where(.ma-checkdraw__input:disabled) {
border-color: GrayText;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-checkdraw__input),
:where(.ma-checkdraw__tick),
:where(.ma-checkdraw__bar) {
transition: none;
}
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | none | Form field name. |
value | string | 'on' | Submitted value. |
checked | boolean | false | Initial state. |
label | string | none | Visible label; the whole label toggles the box. Without it, pass aria-label. |
size | string | '1.25rem' | Box size, any CSS length; the stroke scales with it. |
color | string | none | Box colour when checked; default the ink token. |
--ma-checkdraw-edge | CSS colour (custom property) | the ink token at 45 % | The unchecked box's border. Set it in a rule or style on the component or any ancestor that knows its ground. Use a border measured at 3 : 1 against it (WCAG 1.4.11) where 45 % of the ink falls short, for example on a coloured panel. |
Reduced motion
The box fills and the tick appears at once.
With ClientRouter
A native input; its state travels with the page. Nothing to bind.
Why it is built this way
- The input stays a checkbox in every sense (FormData, required, disabled, the label click, Space, the accessibility tree), and appearance: none only removes the native drawing. The SVG is a sibling that reads :checked and :indeterminate through the general sibling combinator; no :has() needed.
- pathLength="1" makes the dash arithmetic unit-free: dasharray 1, dashoffset 1 is a hidden stroke, 0 is the whole tick, whatever the box size. The tick is drawn on the preset curve after a delay of half the fast token, so the box has filled and the stroke lands on ink.
- Unchecking runs the offset back on the exit curve in the fast duration: the tick leaves the way it came, quickly, and the box empties with it.
- indeterminate is a DOM property, not an attribute, so the bar has no server state; the stage sets it from its script the way a "select all" control would.
- forced-colors rules keep the box visible under Windows high contrast: a ButtonText border, Highlight when checked, the stroke in HighlightText.
Replaces: Checkbox (Motion Primitives) · Checkbox (shadcn / Radix) · AnimatedCheckbox (Aceternity). See the migration table.
Related
idle → pending → done · one attribute
One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.
a checkbox underneath · Space flips it
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown