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):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/notebook-checkboxUsage
<NotebookCheckbox label="" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Label text rendered beside the custom checkbox box. |
| checked | boolean | false | Controlled checked state. |
| onChange | (checked: boolean) => void | — | Change handler — called with the new boolean state. |
| disabled | boolean | false | Disables the control when true. |
| className | string | Additional class names merged onto the root label element. | |
| id | string | — | Unique id for the native input (auto-generated from label if omitted). |
Source
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