Skip to content
Ballpoint
Contents

Installation

Ballpoint is a shadcn registry. You copy the components into your app and own them. It needs React 19 and Tailwind CSS 4; the examples assume Next.js.

1. Set up the paper and ink

In an app that already has Tailwind CSS 4, run init with the Ballpoint base. It writes the colour, type and motion tokens into your global CSS, adds Gaegu with next/font, copies the stroke engine into lib/ and hooks/, and registers the @ballpoint namespace in components.json.

pnpm dlx shadcn@latest init https://ballpoint.st9wd.com/r/ballpoint.json

2. Add components

pnpm dlx shadcn@latest add @ballpoint/button
tsx
import { Button } from "@/components/ui/button"

<Button>Book a call</Button>

Theming

Everything is one ink at different pressures, mixed toward the paper in oklab so the hue never drifts. Change --ink and --paper and the rest follows. The usual shadcn names (--background, --primary, --border, …) are mapped onto these, so shadcn blocks sit on the same page.

Other pens and papers install the same way, for example add @ballpoint/pen-black @ballpoint/paper-white. See pens and papers, or try them together in the customizer.

--paper
The page. Cream by day, navy by night.
--ink
The pen. Text, strokes, focus rings.
--ink-2, --ink-3
Lighter pressure for secondary and muted text. Both clear 4.5:1.
--ink-line
Control borders: the lightest pressure that clears 3:1.
--ink-4, --ink-5
Decoration only: rules, washes, hatching.
--pen-red
The only other pen, for destructive and invalid states.
--ink-fill
How solid a pen-shaded fill is under its strokes.

Pen settings

Strokes are generated from a seed, so a component draws the same wobble on the server and in the browser; pass seed to pin one. By default everything draws itself in the first time it scrolls into view (draw="auto"); with reduced motion it appears already drawn.

How the pen behaves is yours to set, per component or for a whole region: roughness, how many passes, corner radius (pills included), crossed or joined corners, how areas are coloured in, the shadow, line weight and drawing speed. A salt redraws everything inside in a slightly different hand.

app/layout.tsx
import { InkProvider } from "@/hooks/use-ink-box"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <InkProvider radius={10} roughness={0.8} fill="hatch">
          {children}
        </InkProvider>
      </body>
    </html>
  )
}
draw"auto" | "mount" | "none"default "auto"
When the strokes draw themselves in: the first time they scroll into view, as soon as they render, or never (already drawn).
roughnessnumberdefault 1
0 is ruler-neat, 1 a quick confident hand, 2 a scrawl.
passes1 | 2 | 3
How many times an outline is gone over. Each component picks its own default.
radiusnumber | "full"default 0
Corner radius in px; "full" draws a pill.
corners"crossed" | "joined"default "crossed"
Square corners: sides pulled separately past each other, or the box drawn in one motion.
fill"shade" | "hatch" | "scribble" | "flat"
How an area is coloured in. Solid parts default to shade, light ones to hatch.
shadow"hatch" | "solid" | "none"default "hatch"
What a lifted box leaves on the paper. "none" turns the lift off too.
weightnumberdefault 1
Line weight multiplier. Also settable in CSS as --ink-weight.
speednumberdefault 1
Drawing speed multiplier. Also settable in CSS as --ink-speed.

Weight and speed are plain CSS variables too:

css
:root {
  --ink-weight: 1.2; /* heavier pen everywhere */
  --ink-speed: 0.8;  /* draw a little slower */
}