Button
A pen-drawn box that lifts off a hatched shadow. Solid, outline, secondary, ghost, destructive and link.
Built on Base UI Buttonimport { Button } from "@/components/ui/button";
import { InkGlyph } from "@/lib/ink-glyphs";
export default function ButtonDemo() {
return (
<div className="flex flex-col gap-9">
<div className="flex flex-wrap items-center gap-5">
<Button seed="v-default">
Book a call <InkGlyph name="arrow-up-right" />
</Button>
<Button variant="outline" seed="v-outline">
Outline
</Button>
<Button variant="secondary" seed="v-secondary">
Secondary
</Button>
<Button variant="destructive" seed="v-destructive">
Destructive
</Button>
</div>
<div className="flex flex-wrap items-center gap-5">
<Button variant="ghost" seed="v-ghost">
Ghost
</Button>
<Button variant="link" seed="v-link">
Link
</Button>
<Button size="icon" variant="outline" seed="i-outline" aria-label="Add a page">
<InkGlyph name="plus" />
</Button>
<Button size="icon" variant="ghost" seed="i-ghost" aria-label="Next page">
<InkGlyph name="arrow-right" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-5">
<Button size="xs" variant="outline" seed="s-xs">
Extra small
</Button>
<Button size="sm" variant="outline" seed="s-sm">
Small
</Button>
<Button variant="outline" seed="s-default">
Default
</Button>
<Button size="lg" variant="outline" seed="s-lg">
Large
</Button>
</div>
<div className="flex flex-wrap items-center gap-5">
<Button draw="mount" seed="mount">
Draws itself in
</Button>
<Button variant="outline" seed="disabled" disabled>
Disabled
</Button>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ buttonOr copy the source
"use client";
import { Children, useId, useMemo, type CSSProperties, type ReactNode } from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
penStyle,
useInkFrame,
useInkSeed,
usePen,
type InkDraw,
type InkFill,
type InkShadow,
type InkSize,
type Pen,
} from "@/hooks/use-ink-box";
import { InkSvg, Stroke } from "@/lib/ink";
import {
boxStroke,
cornerTicks,
crossedBoxStroke,
hatchStrokes,
linkStroke,
penBoxStrokes,
roundedBoxStroke,
roundedRectPath,
scribbleFill,
shadeFill,
} from "@/lib/ink-sketch";
const buttonVariants = cva(
[
"group/button ink-hover relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap select-none",
"transition-[translate,color] duration-(--dur-hover) ease-out",
"outline-none focus-visible:outline-solid focus-visible:outline-[1.5px] focus-visible:outline-offset-4 focus-visible:outline-ring",
// Rounded buttons get a ring that follows their corners.
"focus-visible:[border-radius:var(--ink-r,var(--hand-radius))]",
"disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg:not(.ink-sketch)]:pointer-events-none [&_svg:not(.ink-sketch)]:shrink-0",
"motion-reduce:transition-none",
],
{
variants: {
variant: {
default: "text-primary-foreground",
outline: "text-foreground",
secondary: "text-foreground",
ghost: "text-ink-2 hover:text-ink focus-visible:text-ink aria-expanded:text-ink",
destructive: "text-destructive",
link: "text-foreground",
},
size: {
default: "h-10 gap-1.5 px-4 text-base [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4.5",
xs: "h-7 gap-1 px-2 text-xs [&_svg:not(.ink-sketch):not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1 px-3 text-sm [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4",
lg: "h-12 gap-2 px-5 text-lg [&_svg:not(.ink-sketch):not([class*='size-'])]:size-5",
icon: "size-10 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4.5",
"icon-xs": "size-7 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-3.5",
"icon-sm": "size-8 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4",
"icon-lg": "size-12 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-5",
},
},
compoundVariants: [{ variant: "link", className: "h-auto px-0.5" }],
defaultVariants: {
variant: "default",
size: "default",
},
},
);
// Boxed buttons lift off their shadow on hover and focus; pressing flattens
// them back onto the paper.
const lift = "hover:-translate-[1.5px] focus-visible:-translate-[1.5px] active:translate-0 active:duration-(--dur-press)";
type Variant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
type Size = NonNullable<VariantProps<typeof buttonVariants>["size"]>;
const metrics: Record<Size, { h: number; font: number; pad: number }> = {
default: { h: 40, font: 20, pad: 16 },
xs: { h: 28, font: 15, pad: 8 },
sm: { h: 32, font: 17, pad: 12 },
lg: { h: 48, font: 22, pad: 20 },
icon: { h: 40, font: 20, pad: 0 },
"icon-xs": { h: 28, font: 15, pad: 0 },
"icon-sm": { h: 32, font: 17, pad: 0 },
"icon-lg": { h: 48, font: 22, pad: 0 },
};
function textLength(node: ReactNode): number {
let n = 0;
Children.forEach(node, (child) => {
if (typeof child === "string" || typeof child === "number") n += String(child).length;
});
return n;
}
/** A first guess at the button's box, for the server render (measured on the client). */
function estimateSize(children: ReactNode, size: Size): InkSize {
const { h, font, pad } = metrics[size];
if (size.startsWith("icon")) return [h, h];
return [Math.round(textLength(children) * font * 0.46 + pad * 2), h];
}
type ButtonProps = ButtonPrimitive.Props &
VariantProps<typeof buttonVariants> &
Pen & {
/** Pins the drawing; by default each button gets its own wobble. */
seed?: string | number;
};
function Button({
className,
style,
variant,
size,
seed,
children,
roughness,
passes,
radius,
corners,
fill,
shadow,
draw,
weight,
speed,
...props
}: ButtonProps) {
const pen = usePen({ roughness, passes, radius, corners, fill, shadow, draw, weight, speed });
const v = variant ?? "default";
const sz = size ?? "default";
const lifts = v !== "ghost" && v !== "link" && pen.shadow !== "none";
const classes = cn(buttonVariants({ variant: v, size: sz }), lifts && lift);
const ring = pen.radius === "full" ? "9999px" : pen.radius ? `${pen.radius}px` : undefined;
const own = ring ? ({ "--ink-r": ring } as CSSProperties) : undefined;
return (
<ButtonPrimitive
data-slot="button"
data-variant={v}
className={typeof className === "function" ? (state) => cn(classes, className(state)) : cn(classes, className)}
style={typeof style === "function" ? (state) => ({ ...own, ...style(state) }) : own || style ? { ...own, ...style } : undefined}
{...props}
>
<ButtonInk variant={v} seed={seed} pen={pen} estimate={estimateSize(children, sz)} />
{children}
</ButtonPrimitive>
);
}
type Look = {
variant: Variant;
roughness: number;
passes: number;
corners: "crossed" | "joined";
fill: InkFill;
shadow: InkShadow;
r: number;
};
type ButtonPaths = {
body?: string;
passes?: string[];
shadow?: string;
ticks?: string;
fill?: string[];
ghost?: string;
underline?: string[];
};
// Paths depend only on (look, seed, size), and the same button is drawn
// again on every re-render and remount, so recent ones are kept.
const pathCache = new Map<string, ButtonPaths>();
/** Only the strokes this look shows: fills are the expensive ones. */
function buttonPaths(look: Look, s: number, w: number, h: number): ButtonPaths {
const key = `${Object.values(look).join("|")}|${s}|${w}|${h}`;
const hit = pathCache.get(key);
if (hit) return hit;
const { variant, roughness: q, passes: n, corners, fill, shadow, r } = look;
// Small buttons get shorter overshoots, so the corners stay neat.
const k = Math.min(1, h / 40);
let paths: ButtonPaths;
if (variant === "link") {
paths = { underline: [linkStroke(s + 10, w), linkStroke(s + 11, w)] };
} else if (variant === "ghost") {
paths = {
ghost: r > 0 ? roundedBoxStroke(s + 8, w, h, r, { jitter: 0.5 * q }) : boxStroke(s + 8, w, h, { overshoot: 2.4 * k * q, jitter: 1.1 * q, bow: 1.3 * q }),
};
} else {
paths = {
body: r > 0 ? roundedRectPath(w, h, r) : `${boxStroke(s + 7, w, h, { overshoot: 0, jitter: 0.9 * q })}Z`,
passes: penBoxStrokes(s, w, h, { roughness: q, passes: n, corners, radius: r }),
};
if (shadow !== "none") {
paths.shadow =
r > 0
? roundedBoxStroke(s + 9, w, h, r, { jitter: 0.4 * q, overrun: 0.02 })
: crossedBoxStroke(s + 9, w, h, { overshoot: 2 * k * q, jitter: q, bow: 1.1 * q });
}
if (variant === "outline" || variant === "destructive") {
paths.ticks = cornerTicks(s + 5, w, h, { count: Math.max(3, Math.round(5 * k)), len: 7 * k });
}
// The solid button is coloured in densely, secondary lightly; outlined
// buttons keep a paper face.
const solid = variant === "default";
if (!solid && variant !== "secondary") {
// No fill.
} else if (fill === "shade") {
paths.fill = solid
? [shadeFill(s + 3, w, h, { gap: 2.3, angle: -14 }), shadeFill(s + 4, w, h, { gap: 4.2, angle: -26 })]
: [shadeFill(s + 6, w, h, { gap: 4.6, angle: -16 })];
} else if (fill === "hatch") {
paths.fill = solid
? [hatchStrokes(s + 3, w, h, { gap: 2.6, angle: -50, jitter: 0.4 }).join(""), hatchStrokes(s + 4, w, h, { gap: 3.6, angle: 42, jitter: 0.4 }).join("")]
: [hatchStrokes(s + 6, w, h, { gap: 4.6, angle: -50, jitter: 0.5, inset: 1.5 }).join("")];
} else if (fill === "scribble") {
paths.fill = solid ? [scribbleFill(s + 3, w, h, { gap: 2.4 }), scribbleFill(s + 4, w, h, { gap: 3.8, slant: 0.3 })] : [scribbleFill(s + 6, w, h, { gap: 5 })];
}
}
if (pathCache.size >= 500) pathCache.delete(pathCache.keys().next().value!);
pathCache.set(key, paths);
return paths;
}
/** The drawn part of a button, regenerated to the button's real size. */
function ButtonInk({ variant, seed, pen, estimate }: { variant: Variant; seed?: string | number; pen: Pen; estimate: InkSize }) {
const uid = useId();
const id = uid.replace(/[^\w-]/g, "");
const s = useInkSeed(seed);
const { ref, w, h, frame } = useInkFrame(estimate);
const draw: InkDraw = pen.draw ?? "auto";
const roughness = pen.roughness ?? 1;
const passes = pen.passes ?? (variant === "secondary" ? 2 : 3);
const corners = pen.corners ?? "crossed";
const fillStyle = pen.fill ?? (variant === "secondary" ? "hatch" : "shade");
const shadowStyle = pen.shadow ?? "hatch";
const r = pen.radius === "full" ? h / 2 : Math.min(pen.radius ?? 0, h / 2);
const paths = useMemo(
() => buttonPaths({ variant, roughness, passes, corners, fill: fillStyle, shadow: shadowStyle, r }, s, w, h),
[variant, roughness, passes, corners, fillStyle, shadowStyle, r, s, w, h],
);
const at = (delay: number) => ({ draw, delay });
const style = { ...frame.style, ...penStyle(pen) };
const pending = draw === "auto";
if (paths.underline) {
return (
<InkSvg ref={ref} pending={pending} box={[0, -2, w, 6]} stretch className="-z-10 inset-x-0 top-full -mt-1 h-1.5 w-full" style={penStyle(pen)}>
{/* Lightly underlined at rest, inked over on hover. */}
<Stroke d={paths.underline[0]} {...at(0)} duration={320} width={1.4} opacity={0.5} />
<Stroke d={paths.underline[1]} draw="hover" duration={280} width={1.5} />
</InkSvg>
);
}
if (paths.ghost) {
return (
<InkSvg ref={ref} {...frame} style={style} className="-z-10">
<Stroke d={paths.ghost} draw="hover" duration={340} width={1.2} />
</InkSvg>
);
}
const { body = "", passes: outlines = [], shadow, fill, ticks } = paths;
const solid = variant === "default";
const flat = fillStyle === "flat";
const { box } = frame;
return (
// A coloured-in face is drawn in ink; its label is paper-coloured.
<InkSvg ref={ref} {...frame} pending={pending} style={style} className={cn("-z-10", solid && "text-primary")}>
<defs>
{shadowStyle === "hatch" && (
<pattern id={`${id}-hatch`} patternUnits="userSpaceOnUse" width={3.2} height={3.2} patternTransform="rotate(-45)">
<path d="M0 -1V4.2" style={{ strokeWidth: 1.05 }} />
</pattern>
)}
{/* The shadow is masked out under the face rather than covered by
it, so the face stays the paper itself. */}
{shadow && (
<mask id={`${id}-under`} maskUnits="userSpaceOnUse" x={box[0] - 20} y={box[1] - 20} width={box[2] + 40} height={box[3] + 40}>
<rect x={box[0] - 20} y={box[1] - 20} width={box[2] + 40} height={box[3] + 40} fill="#fff" />
<path d={body} style={{ fill: "#000", stroke: "#000", strokeWidth: 1.5 }} />
</mask>
)}
{fill && (
<clipPath id={`${id}-clip`}>
<rect x={-1.5} y={-1.5} width={w + 3} height={h + 3} rx={r || 1.5} />
</clipPath>
)}
</defs>
{shadow && (
<g mask={`url(#${id}-under)`}>
<g
className={cn(
"opacity-0 transition-[translate,opacity] duration-(--dur-hover) ease-out motion-reduce:transition-none",
"group-hover/button:translate-[5px] group-hover/button:opacity-100",
"group-focus-visible/button:translate-[5px] group-focus-visible/button:opacity-100",
"group-active/button:translate-0 group-active/button:duration-(--dur-press)",
)}
>
<path
d={body}
style={shadowStyle === "hatch" ? { fill: `url(#${id}-hatch)`, stroke: "none" } : { fill: "currentColor", stroke: "none", opacity: 0.85 }}
/>
<path d={shadow} style={{ strokeWidth: 1 }} />
</g>
</g>
)}
{/* The face: shaded solid with the pen (the strokes read through the
fill), or a light wash for a flat secondary. */}
{solid && <path d={body} className="ink-fill" style={flat ? { opacity: 1 } : undefined} />}
{variant === "secondary" && flat && <path d={body} className="ink-fill" style={{ opacity: 0.14 }} />}
{fill && (
<g clipPath={`url(#${id}-clip)`}>
{fill.map((d, i) =>
solid ? (
<Stroke key={i} d={d} {...at(120 + i * 180)} duration={620 - i * 100} width={[1.2, 1][i]} opacity={[1, 0.7][i]} />
) : (
<Stroke key={i} d={d} {...at(200)} duration={480} width={0.9} opacity={fillStyle === "hatch" ? 0.45 : 0.35} />
),
)}
</g>
)}
{/* Passes that never quite line up, each running past its corners,
the way a box gets gone over when it matters. */}
{outlines.map((d, i) => (
<Stroke
key={i}
d={d}
{...at([0, 300, 520][i])}
duration={[480, 420, 380][i]}
width={[1.4, 1.1, 1][i]}
opacity={i === 0 ? undefined : variant === "secondary" ? 0.6 : [1, 0.8, 0.55][i]}
/>
))}
{ticks && <Stroke d={ticks} {...at(760)} duration={260} width={1} opacity={0.7} />}
</InkSvg>
);
}
export { Button, buttonVariants };Usage
import { Button } from "@/components/ui/button"
export function Actions() {
return (
<>
<Button>Book a call</Button>
<Button variant="outline">Copy email</Button>
</>
)
}Examples
Pen settings
Corners, roughness, weight, fills and shadows, per button or for a whole group with InkProvider.
import { Button } from "@/components/ui/button";
import { InkProvider } from "@/hooks/use-ink-box";
export default function ButtonPens() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-center gap-5">
<Button radius={10} seed="pen-rounded">
Rounded
</Button>
<Button variant="outline" radius="full" seed="pen-pill">
Pill
</Button>
<Button variant="secondary" radius={10} seed="pen-rounded-2">
Rounded hatch
</Button>
<Button variant="outline" corners="joined" passes={2} seed="pen-joined">
Joined corners
</Button>
</div>
<div className="flex flex-wrap items-center gap-5">
<Button roughness={0} passes={1} seed="pen-neat">
Ruler-neat
</Button>
<Button roughness={2} seed="pen-scrawl">
Scrawled
</Button>
<Button variant="outline" weight={1.8} seed="pen-heavy">
Heavy pen
</Button>
<Button variant="outline" shadow="solid" seed="pen-solid-shadow">
Solid shadow
</Button>
</div>
<div className="flex flex-wrap items-center gap-5">
<Button fill="hatch" seed="pen-fill-hatch">
Cross-hatched
</Button>
<Button fill="scribble" seed="pen-fill-scribble">
Scribbled
</Button>
<Button fill="flat" seed="pen-fill-flat">
Flat ink
</Button>
<Button variant="secondary" fill="flat" seed="pen-fill-wash">
Wash
</Button>
</div>
{/* Pen settings apply to everything inside a provider. */}
<InkProvider radius="full" roughness={1.4} shadow="none">
<div className="flex flex-wrap items-center gap-5">
<Button seed="pen-group-1">Save</Button>
<Button variant="outline" seed="pen-group-2">
Cancel
</Button>
<Button variant="destructive" seed="pen-group-3">
Delete
</Button>
</div>
</InkProvider>
</div>
);
}API reference
- variant
"default" | "outline" | "secondary" | "ghost" | "destructive" | "link"default"default" - Shaded solid, boxed, hatched, drawn on hover, red pen, or underlined.
- size
"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg"default"default" - Height and type size. Icon sizes are square.
- seed
string | number - Pins the drawing. By default each button gets its own wobble, stable between server and client.
Every other prop goes to Base UI's Button.
Pen settings
The drawing also takes draw, roughness, passes, radius, corners, fill, shadow, weight and speed, as props or from the nearest InkProvider. What each one does.