Switch
A pill pulled in one motion, shaded in with the pen as the thumb slides across.
Built on Base UI SwitchStrokes draw themselves the first time they scroll into view.
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export default function SwitchDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="switch-draw">Draw things in</FieldLabel>
<FieldDescription>Strokes draw themselves the first time they scroll into view.</FieldDescription>
</FieldContent>
<Switch id="switch-draw" defaultChecked seed="switch-draw" />
</Field>
<div className="flex flex-wrap items-center gap-6">
<Label>
<Switch seed="switch-off" />
Off
</Label>
<Label>
<Switch size="sm" defaultChecked seed="switch-sm" />
Small
</Label>
<Label>
<Switch fill="hatch" defaultChecked seed="switch-hatch" />
Hatched
</Label>
<Label>
<Switch fill="flat" defaultChecked seed="switch-flat" />
Flat
</Label>
<Label>
<Switch disabled seed="switch-disabled" />
Disabled
</Label>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ switchOr copy the source
"use client";
import { useId, useMemo } from "react";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
import { penStyle, useInkSeed, usePen, type Pen } from "@/hooks/use-ink-box";
import { InkSvg, Stroke } from "@/lib/ink";
import { InkOutline } from "@/lib/ink-outline";
import { ringStroke, roundedRectPath, scribbleFill, shadeFill, hatchStrokes } from "@/lib/ink-sketch";
const sizes = {
default: { w: 44, h: 24, thumb: 18 },
sm: { w: 32, h: 18, thumb: 12 },
} as const;
/**
* A pill drawn in one pull with a ring for a thumb. Switching on shades the
* track in with the pen as the thumb slides across; switching off pulls the
* shading back out.
*/
function Switch({
className,
size = "default",
seed,
roughness,
passes,
fill,
draw,
weight,
speed,
...props
}: SwitchPrimitive.Root.Props &
Pick<Pen, "roughness" | "passes" | "fill" | "draw" | "weight" | "speed"> & { size?: keyof typeof sizes; seed?: string | number }) {
const pen = usePen({ roughness, passes, fill, draw, weight, speed });
const uid = useId().replace(/[^\w-]/g, "");
const { w, h, thumb } = sizes[size];
const fillStyle = pen.fill ?? "shade";
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 cursor-pointer items-center rounded-full outline-none",
"after:absolute after:-inset-x-3 after:-inset-y-2",
"text-ink-3 transition-colors duration-(--dur-hover) not-data-checked:hover:text-ink data-checked:text-ink",
"aria-invalid:text-destructive data-invalid:text-destructive",
"focus-visible:outline-solid focus-visible:outline-[1.5px] focus-visible:outline-offset-4 focus-visible:outline-ring",
"data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
style={{ width: w, height: h }}
{...props}
>
<InkOutline pen={{ ...pen, radius: "full", corners: "joined" }} seed={seed} estimate={[w, h]} pad={6}>
{({ w: tw, h: th, s }) => (
<>
<defs>
<clipPath id={`${uid}-track`}>
<path d={roundedRectPath(tw, th, th / 2)} />
</clipPath>
</defs>
<g clipPath={`url(#${uid}-track)`}>
<TrackFill fill={fillStyle} s={s} w={tw} h={th} />
</g>
</>
)}
</InkOutline>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none absolute left-[3px] block text-ink transition-transform duration-(--dur-state) ease-out data-checked:translate-x-(--on) motion-reduce:transition-none"
style={{ top: (h - thumb) / 2, width: thumb, height: thumb, ["--on" as string]: `${w - thumb - 6}px` }}
>
<ThumbInk seed={seed} size={thumb} pen={pen} />
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root>
);
}
/** The track's colouring-in, drawn while the switch is on. */
function TrackFill({ fill, s, w, h }: { fill: NonNullable<Pen["fill"]>; s: number; w: number; h: number }) {
const strokes = useMemo(() => {
if (fill === "flat") return [];
if (fill === "hatch") return [hatchStrokes(s + 50, w, h, { gap: 2.4, angle: -50, jitter: 0.3 }).join("")];
if (fill === "scribble") return [scribbleFill(s + 50, w, h, { gap: 2.2 })];
return [shadeFill(s + 50, w, h, { gap: 1.9, angle: -12, overrun: 2 })];
}, [fill, s, w, h]);
// Flat: one stroke as wide as the track, pulled left to right.
if (fill === "flat") return <Stroke d={`M${h / 2} ${h / 2}H${w - h / 2}`} draw="checked" duration={260} width={h} />;
return strokes.map((d, i) => <Stroke key={i} d={d} draw="checked" duration={420} width={1.3} />);
}
/** A small paper disc with a drawn ring, so the shaded track doesn't show through. */
function ThumbInk({ seed, size, pen }: { seed?: string | number; size: number; pen: Pen }) {
const s = useInkSeed(seed === undefined ? undefined : `${seed}-thumb`);
const ring = useMemo(() => ringStroke(s + 60, size, { turns: 1.08 }), [s, size]);
return (
<InkSvg box={[-2, -2, size + 4, size + 4]} className="inset-[-2px]" style={{ width: size + 4, height: size + 4, ...penStyle(pen) }}>
<circle cx={size / 2} cy={size / 2} r={size / 2} style={{ fill: "var(--paper)" }} />
<Stroke d={ring} width={1.4} />
</InkSvg>
);
}
export { Switch };Usage
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
<Label>
<Switch defaultChecked />
Draw things in
</Label>API reference
- size
"default" | "sm"default"default" - Track size.
- seed
string | number - Pins the drawing.
Every other prop goes to Base UI's Switch.
Pen settings
The drawing also takes draw, roughness, passes, fill, weight and speed, as props or from the nearest InkProvider. What each one does.