Skip to content

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.

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

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/check-draw.json

The 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.astro
---
/**
 * 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

PropTypeDefaultDescription
namestringnoneForm field name.
valuestring'on'Submitted value.
checkedbooleanfalseInitial state.
labelstringnoneVisible label; the whole label toggles the box. Without it, pass aria-label.
sizestring'1.25rem'Box size, any CSS length; the stroke scales with it.
colorstringnoneBox colour when checked; default the ink token.
--ma-checkdraw-edgeCSS 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

Replaces: Checkbox (Motion Primitives) · Checkbox (shadcn / Radix) · AnimatedCheckbox (Aceternity). See the migration table.

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.

Chrome 52 · Firefox 97 · not Safariclick

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.

every browserclick

a checkbox underneath · Space flips it

A toggle that springs.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown