bottega

primitives

Notebook Checkbox

A hand-drawn-style checkbox that mimics the feel of ink on paper using an inline SVG displacement filter (feTurbulence + feDisplacementMap) applied to the box, text, and strikethrough. Checking the box triggers a radial ink-dot animation that fills from center outward; unchecking plays the reverse collapse. The label simultaneously fades to a muted foreground tone and a horizontal SVG line sweeps left-to-right across the text as a strikethrough — both driven purely by CSS sibling selectors and transitions off the native input's :checked state. The component uses a real visually-hidden <input type="checkbox"> for controlled state, a11y, and keyboard support; all visual layers are decorative.

Ported from AatreyuShau (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/notebook-checkbox

Usage

Usagetsx
<NotebookCheckbox label="" />

Props

PropTypeDefaultDescription
label*stringLabel text rendered beside the custom checkbox box.
checkedbooleanfalseControlled checked state.
onChange(checked: boolean) => voidChange handler — called with the new boolean state.
disabledbooleanfalseDisables the control when true.
classNamestringAdditional class names merged onto the root label element.
idstringUnique id for the native input (auto-generated from label if omitted).

Source

notebook-checkbox.tsxtsx
import type React from "react";
import styles from "./notebook-checkbox.module.css";

export type NotebookCheckboxProps = {
  /** Label text rendered beside the custom checkbox box. */
  label: string;
  /** Controlled checked state. */
  checked?: boolean;
  /** Change handler — called with the new boolean state. */
  onChange?: (checked: boolean) => void;
  /** Disables the control when true. */
  disabled?: boolean;
  /** Additional class names merged onto the root label element. */
  className?: string;
  /** Unique id for the native input (auto-generated from label if omitted). */
  id?: string;
};

/**
 * Notebook Checkbox — a hand-drawn-style checkbox using an inline SVG
 * displacement filter (feTurbulence + feDisplacementMap) to give the box and
 * text a sketchy ink feel. Checking fills in a radial ink-dot inside the box
 * via a CSS animation; the label text gets a horizontal strikethrough line
 * that sweeps left-to-right via an SVG <path> + scaleX transition.
 */
export function NotebookCheckbox({
  label,
  checked = false,
  onChange,
  disabled = false,
  className = "",
  id,
}: NotebookCheckboxProps) {
  const inputId = id ?? `notebook-checkbox-${label.toLowerCase().replace(/\s+/g, "-")}`;

  return (
    <>
      {/* Inline SVG noise filter — zero-size, purely a filter source. aria-hidden. */}
      <svg
        height="0"
        width="0"
        aria-hidden="true"
        focusable="false"
        className={styles.filterDefs}
      >
        <defs>
          <filter id={`handDrawnNoise-${inputId}`}>
            <feTurbulence
              result="noise"
              numOctaves={8}
              baseFrequency={0.1}
              type="fractalNoise"
            />
            <feDisplacementMap
              yChannelSelector="G"
              xChannelSelector="R"
              scale={3}
              in2="noise"
              in="SourceGraphic"
            />
          </filter>
        </defs>
      </svg>

      <label
        htmlFor={inputId}
        className={[styles.root, disabled ? styles.disabled : "", className]
          .filter(Boolean)
          .join(" ")}
        /* Pass the filter id via CSS var so all sub-elements can reference it
           without needing JS-driven inline style on every child. */
        style={
          { "--filter-id": `url(#handDrawnNoise-${inputId})` } as React.CSSProperties
        }
      >
        {/* Visually-hidden real native input — carries all a11y state. */}
        <input
          type="checkbox"
          id={inputId}
          checked={checked}
          disabled={disabled}
          onChange={(e) => onChange?.(e.target.checked)}
          className={styles.input}
        />

        {/* Decorative custom box: border + radial ink-dot animation. */}
        <span aria-hidden="true" className={styles.checkmark} />

        {/* Label text + SVG strikethrough line. */}
        <span className={styles.text}>
          <span className={styles.textContent}>{label}</span>
          <svg
            preserveAspectRatio="none"
            viewBox="0 0 400 20"
            aria-hidden="true"
            focusable="false"
            className={styles.cutLine}
          >
            <path d="M0,10 H400" />
          </svg>
        </span>
      </label>
    </>
  );
}

Dependencies

  • @bottega/tokens