diff --git a/README.md b/README.md
index 40963d8..e8cb54d 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ We're all excited about `corner-shape: squircle`, but we're in a pickle right no
- [Requirements](#requirements)
- [Install & setup](#install--setup)
+- [Pill Shapes with Houdini CSS Paint Worklet](#pill-shapes-with-houdini-css-paint-worklet)
- [How the radius correction works](#how-the-radius-correction-works)
- [Browser support & fallback strategy](#browser-support--fallback-strategy)
- [Why it called "squircle" when it use "superellipse()"?](#why-it-called-squircle-when-it-use-superellipse)
@@ -443,6 +444,94 @@ The parameters are deliberately untyped so relative units (`em`, `rem`, containe
+## Pill Shapes with Houdini CSS Paint Worklet
+
+The standard `corner-shape: superellipse()` can't perfectly render pill shapes because it creates hard corners where the curved ends meet the straight sides. **Pill shapes** use a Houdini CSS Paint API worklet to render mathematically smooth, G2-continuous transitions from semicircular ends to straight edges—ideal for button pills, badge pills, and other pill-shaped UI elements.
+
+
+Tailwind CSS v4
+
+### 1. Install the pill plugin
+
+```bash
+npm install @klinking/squircle
+```
+
+### 2. Add the pill plugin to your CSS
+
+```css
+@import "tailwindcss";
+@import "@klinking/squircle/tailwind-pill";
+@import "@klinking/squircle/squircle-pill.css";
+```
+
+Or with the JS plugin version:
+
+```css
+@import "tailwindcss";
+@plugin "@klinking/squircle/tailwind-pill";
+@import "@klinking/squircle/squircle-pill.css";
+```
+
+### 3. Register the paint worklet
+
+In your entry point (e.g., `main.ts`), register the Houdini paint worklet:
+
+```typescript
+// Register the paint worklet
+CSS.paintWorklet.addModule(
+ new URL("@klinking/squircle/pill-shape.worklet.js", import.meta.url).href
+);
+```
+
+### 4. Use the pill utility
+
+Use the `squircle-pill` class to apply pill shapes to any element:
+
+```html
+
+
+
+
+
New
+
+
+
…
+
+
+
…
+```
+
+**Available variants:**
+
+- Base utility: `squircle-pill`
+- Side variants: `squircle-pill-t`, `squircle-pill-r`, `squircle-pill-b`, `squircle-pill-l` (top, right, bottom, left)
+- Corner variants: `squircle-pill-tl`, `squircle-pill-tr`, `squircle-pill-br`, `squircle-pill-bl` (and logical equivalents)
+- Amount control: `squircle-pill-amt-*` (e.g., `squircle-pill-amt-1`, `squircle-pill-amt-2.5`, `squircle-pill-amt-3`) to adjust the smoothness of the semicircle-to-edge transition
+
+The pill radius is automatically calculated from the element's dimensions: `radius = min(width, height) / 2`, ensuring perfect pills at any size.
+
+### Browser support and fallback
+
+- **Chrome/Edge 89+**: Full Houdini CSS Paint API support — perfect pill shapes with G2-continuous curves
+- **Safari/Firefox**: Graceful fallback to `corner-shape: superellipse()` which approximates a pill shape
+
+
+
+### How pill shapes work
+
+Pill shapes render using Houdini's CSS Paint API to draw:
+
+- **Horizontal pills** (width > height): Semicircles on left and right, straight top and bottom edges
+- **Vertical pills** (height > width): Semicircles on top and bottom, straight left and right edges
+- **Circular pills** (width = height): Full circles
+
+The worklet computes G2-continuous Bezier curves at junctions between the semicircles and straight edges, using cubic Bezier approximation (magic constant `0.55228`) to smoothly transition from the semicircle's constant curvature to the straight edge's zero curvature.
+
+**Fallback for unsupported browsers:** Falls back to `corner-shape: superellipse()` which approximates a pill shape without perfect mathematical continuity.
+
## How the radius correction works
A superellipse at the same outer `border-radius` as a circular arc pokes further into the corner. The fix is to scale the radius up by some maths, so the _apparent_ roundness matches what you'd get from `rounded-*`. That is, the distance from the corner to the maximum pokage will match for both the superelliptical corner and the circular corner.
diff --git a/package/index.html b/package/index.html
new file mode 100644
index 0000000..a683345
--- /dev/null
+++ b/package/index.html
@@ -0,0 +1,467 @@
+
+
+
+ The caps stay circular arcs. amt sets how much of each cap
+ is handed to the easing — 1 is a bare semicircle, identical to
+ border-radius: 9999px and only G1. spread smooths that join
+ further along the flat edge without spending more of the arc, so raising it lets
+ the amount come back down. 0 is a clothoid and the sane floor: below it the
+ easing shortens and arrives ever more steeply, and at -2 it is a bare
+ corner. When the element is too narrow for the request, both are eased down together so the
+ curvature rate stays put; a square has no flat edge at all and renders as a plain circle.
+
+
+
+
+
+
Perfect Pills
+
+
+
Badge
+
+ ✓
+
+
+
+
+
+
Different Aspect Ratios
+
+
Wide
+
Tall
+
+ Square
+
+
+
+
+
+
Backgrounds, Borders & Shadows
+
+ The shape is a mask, so the element keeps its own background and that
+ background gets pill-shaped. The flip side: a mask erases everything outside the shape, so
+ outline and an outer box-shadow cannot survive on the pill
+ itself, and filter runs before the mask so even drop-shadow set
+ on the pill is clipped away. Each row below is the technique that does work.
+
+
+
+
+
Linear gradient
+
+
+
+
+
Conic gradient
+
+
+
+
+
Background image
+
+
+
+
+
+ Border — --%SQUIRCLE_NS%-pill-border-width
+
+
+
+
+
+
Border only, no background
+
+
+
+
+
+ Dashed — from --tw-border-style
+
+
+
+
+
+
+ Dotted — from --tw-border-style
+
+
+
+
+
+
Inner outline — inset pseudo
+
+
+
+
+
Outer outline — masked wrapper
+
+
+
+
+
+
+
Drop shadow — wrapper filter
+
+
+
+
+
+
+
Inner shadow — inset on pseudo
+
+
+
+
+
Everything at once
+
+
+
+
+
+
+
+
+
Use Cases
+
+
+
Buttons
+
+
+
+
+
+
+
+
+
Tags & Badges
+
+ Tag
+ Status
+ Warning
+
+
+
+
+
Icon Buttons
+
+
+
+
+
+
+
+
+
+
+
+ Note:@supports (background-image: paint(anything)) is true
+ in Chrome for any paint name, registered or not — so DevTools showing
+ paint(pill-shape) as applied does not mean the worklet loaded. Check
+ document.documentElement.dataset.worklet (or the console) instead.
+
+
+
+
+
diff --git a/package/package.json b/package/package.json
index d82d56d..68cd9d7 100644
--- a/package/package.json
+++ b/package/package.json
@@ -26,7 +26,21 @@
"import": "./dist/tailwind/index.mjs"
},
"./tailwind/utils.css": "./dist/tailwind/utils.css",
+ "./tailwind-pill": {
+ "types": "./dist/tailwind-pill/index.d.mts",
+ "import": "./dist/tailwind-pill/index.mjs"
+ },
+ "./tailwind-pill-border": {
+ "types": "./dist/tailwind-pill-border/index.d.mts",
+ "import": "./dist/tailwind-pill-border/index.mjs"
+ },
"./radius-function.css": "./dist/radius-function.css",
+ "./squircle-pill.css": "./dist/squircle-pill.css",
+ "./pill-shape.worklet": {
+ "types": "./dist/pill-shape.worklet.d.mts",
+ "import": "./dist/pill-shape.worklet.mjs"
+ },
+ "./pill-shape.worklet.js": "./dist/pill-shape.worklet.mjs",
"./panda": {
"types": "./dist/panda/index.d.mts",
"import": "./dist/panda/index.mjs"
@@ -37,6 +51,7 @@
}
},
"scripts": {
+ "dev": "vp run pill-dev",
"prepublishOnly": "vp run build"
},
"devDependencies": {
@@ -74,5 +89,8 @@
"optional": true
}
},
- "packageManager": "pnpm@10.33.0"
+ "packageManager": "pnpm@10.33.0",
+ "squircle": {
+ "cssNamespace": "squircle"
+ }
}
diff --git a/package/scripts/copy-pill-assets.ts b/package/scripts/copy-pill-assets.ts
new file mode 100644
index 0000000..4d75011
--- /dev/null
+++ b/package/scripts/copy-pill-assets.ts
@@ -0,0 +1,33 @@
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const distDir = join(__dirname, "..", "dist");
+mkdirSync(distDir, { recursive: true });
+
+/*
+ * Copy the pill stylesheet, rewriting the custom-property namespace if one was
+ * configured. The source carries the default so it stays readable and lintable;
+ * the worklet and the plugins take the same value through a build-time define,
+ * so all three agree however it is set.
+ */
+const DEFAULT_CSS_NAMESPACE = "squircle";
+const cssNamespace: string =
+ process.env.SQUIRCLE_CSS_NAMESPACE ||
+ JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).squircle?.cssNamespace ||
+ DEFAULT_CSS_NAMESPACE;
+
+const pillCssSrc = join(__dirname, "..", "src", "squircle-pill.css");
+const pillCssDest = join(distDir, "squircle-pill.css");
+const pillCss = readFileSync(pillCssSrc, "utf8").replaceAll(
+ `--${DEFAULT_CSS_NAMESPACE}-pill-`,
+ `--${cssNamespace}-pill-`,
+);
+writeFileSync(pillCssDest, pillCss);
+console.log(`Copied ${pillCssDest} (namespace: ${cssNamespace})`);
+
+// Note: pill-shape.worklet.ts will be bundled by vp pack as a regular module
+// Users will need to register it with CSS.paintWorklet.addModule() in their app
+console.log("Pill shape worklet will be bundled as pill-shape.worklet.mjs");
diff --git a/package/src/panda.ts b/package/src/panda.ts
index 2566f5e..248f152 100644
--- a/package/src/panda.ts
+++ b/package/src/panda.ts
@@ -7,6 +7,7 @@ import { definePreset, type PropertyConfig } from "@pandacss/dev";
import {
CAMEL_VARIANTS,
DEFAULT_AMOUNT_VAR_NAME,
+ DEFAULT_R_VAR_NAME,
SUPPORTS_RULE,
squircleCssObj,
variantEntries,
@@ -14,8 +15,17 @@ import {
export interface SquirclePandaPresetOptions {
/** CSS custom property name for the superellipse amount (default: "--squircle-amt"). */
+ /**
+ * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at
+ * build time, which renames every property this package owns together. This
+ * option still works and still wins.
+ */
amtVar?: string;
/** CSS custom property name for the intermediate corrected radius (default: "--squircle-r"). */
+ /**
+ * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at
+ * build time. This option still works and still wins.
+ */
rVar?: string;
}
@@ -38,7 +48,7 @@ export interface SquirclePandaPresetOptions {
*/
export function squirclePandaPreset(options: SquirclePandaPresetOptions = {}) {
const amtVar = options.amtVar ?? DEFAULT_AMOUNT_VAR_NAME;
- const rVar = options.rVar ?? "--squircle-r";
+ const rVar = options.rVar ?? DEFAULT_R_VAR_NAME;
const utilities: Record = {};
diff --git a/package/src/pill-shape.geometry.test.ts b/package/src/pill-shape.geometry.test.ts
new file mode 100644
index 0000000..99383bb
--- /dev/null
+++ b/package/src/pill-shape.geometry.test.ts
@@ -0,0 +1,514 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import { describe, expect, it } from "vitest";
+import { paintDef } from "./pill-shape.worklet";
+import { PILL_AMT_VAR_NAME, PILL_EASE_SPREAD_VAR_NAME } from "./variants";
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+/** Records the polyline the worklet emits — that polyline is the shape. */
+class RecordingContext {
+ fillStyle: unknown = "";
+ vertices: Point[] = [];
+ closed = false;
+
+ beginPath(): void {}
+ fill(): void {}
+ moveTo(x: number, y: number): void {
+ this.vertices.push({ x, y });
+ }
+ lineTo(x: number, y: number): void {
+ this.vertices.push({ x, y });
+ }
+ arc(): void {
+ throw new Error("the outline must be emitted as a polyline");
+ }
+ closePath(): void {
+ this.closed = true;
+ }
+}
+
+const props = (amt?: number, spread?: number) => ({
+ get(name: string) {
+ if (name === PILL_AMT_VAR_NAME && amt !== undefined) {
+ return { toString: () => String(amt) };
+ }
+ if (name === PILL_EASE_SPREAD_VAR_NAME && spread !== undefined) {
+ return { toString: () => String(spread) };
+ }
+ return undefined;
+ },
+});
+
+const paint = (width: number, height: number, amt?: number, spread?: number): RecordingContext => {
+ const ctx = new RecordingContext();
+ const instance = new (paintDef as new () => {
+ paint(c: unknown, s: { width: number; height: number }, p: unknown): void;
+ })();
+ instance.paint(ctx, { width, height }, props(amt, spread));
+ return ctx;
+};
+
+/** Circle through three points; used to measure the cap without assuming it. */
+const circleThrough = (a: Point, b: Point, c: Point): { x: number; y: number; r: number } => {
+ const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
+ const sq = (p: Point) => p.x * p.x + p.y * p.y;
+ const x = (sq(a) * (b.y - c.y) + sq(b) * (c.y - a.y) + sq(c) * (a.y - b.y)) / d;
+ const y = (sq(a) * (c.x - b.x) + sq(b) * (a.x - c.x) + sq(c) * (b.x - a.x)) / d;
+ return { x, y, r: Math.hypot(a.x - x, a.y - y) };
+};
+
+/** Menger curvature at b, given its neighbours a and c. */
+const curvature = (a: Point, b: Point, c: Point): number => {
+ const ab = Math.hypot(b.x - a.x, b.y - a.y);
+ const bc = Math.hypot(c.x - b.x, c.y - b.y);
+ const ca = Math.hypot(a.x - c.x, a.y - c.y);
+ if (ab === 0 || bc === 0 || ca === 0) return 0;
+ const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
+ return (2 * Math.abs(cross)) / (ab * bc * ca);
+};
+
+/** Index of the vertex where the top-left outline meets the flat top edge. */
+const junctionIndex = (ctx: RecordingContext): number => {
+ let best = 0;
+ let longest = 0;
+ for (let i = 1; i < ctx.vertices.length; i++) {
+ const d = Math.hypot(
+ ctx.vertices[i].x - ctx.vertices[i - 1].x,
+ ctx.vertices[i].y - ctx.vertices[i - 1].y,
+ );
+ if (d > longest) {
+ longest = d;
+ best = i - 1;
+ }
+ }
+ return best;
+};
+
+/**
+ * Largest rate of curvature change over the cap, scaled to be size-independent.
+ * This is what reads as a smooth or an abrupt transition.
+ */
+const curvatureGradient = (ctx: RecordingContext, r: number): number => {
+ const j = junctionIndex(ctx);
+ const v = ctx.vertices;
+ let worst = 0;
+ for (let i = 2; i < j; i++) {
+ const before = curvature(v[i - 2], v[i - 1], v[i]);
+ const after = curvature(v[i - 1], v[i], v[i + 1]);
+ const ds = Math.hypot(v[i].x - v[i - 1].x, v[i].y - v[i - 1].y);
+ if (ds > 1e-9) worst = Math.max(worst, Math.abs(after - before) / ds);
+ }
+ return worst * r * r;
+};
+
+/**
+ * Angle, in degrees, at which the outline arrives at the flat edge. Curvature
+ * decaying to zero shows up here as arriving tangent; a cliff arrives steeply.
+ * Unlike a curvature sample, this does not depend on where vertices happen to
+ * land.
+ */
+const arrivalAngle = (ctx: RecordingContext): number => {
+ const j = junctionIndex(ctx);
+ const dx = ctx.vertices[j].x - ctx.vertices[j - 1].x;
+ const dy = ctx.vertices[j].y - ctx.vertices[j - 1].y;
+ return Math.abs((Math.atan2(dy, dx) * 180) / Math.PI);
+};
+
+/** Curvature profile over the cap, ending at the flat-edge junction. */
+const capCurvature = (ctx: RecordingContext): number[] => {
+ const j = junctionIndex(ctx);
+ const out: number[] = [];
+ for (let i = 1; i < j; i++) {
+ out.push(curvature(ctx.vertices[i - 1], ctx.vertices[i], ctx.vertices[i + 1]));
+ }
+ return out;
+};
+
+const WIDTH = 240;
+const HEIGHT = 60;
+const R = HEIGHT / 2;
+
+describe("pill-shape worklet geometry", () => {
+ describe("outline", () => {
+ it("fills the box exactly and closes", () => {
+ const ctx = paint(WIDTH, HEIGHT);
+ const xs = ctx.vertices.map((p) => p.x);
+ const ys = ctx.vertices.map((p) => p.y);
+
+ expect(Math.min(...xs)).toBeCloseTo(0, 4);
+ expect(Math.max(...xs)).toBeCloseTo(WIDTH, 4);
+ expect(Math.min(...ys)).toBeCloseTo(0, 4);
+ expect(Math.max(...ys)).toBeCloseTo(HEIGHT, 4);
+ expect(ctx.closed).toBe(true);
+ });
+
+ it("keeps flat top and bottom edges between the caps", () => {
+ const ctx = paint(WIDTH, HEIGHT);
+ const j = junctionIndex(ctx);
+ const a = ctx.vertices[j];
+ const b = ctx.vertices[j + 1];
+
+ expect(a.y).toBeCloseTo(0, 6);
+ expect(b.y).toBeCloseTo(0, 6);
+ expect(b.x - a.x).toBeGreaterThan(0);
+ // Mirrored, so the edge is centred.
+ expect(a.x + b.x).toBeCloseTo(WIDTH, 4);
+ });
+ });
+
+ describe("caps stay round", () => {
+ it("keeps a cap radius close to half the height", () => {
+ const ctx = paint(WIDTH, HEIGHT);
+ // Derive the circle from three arc samples rather than assuming where
+ // its centre is — the easing pulls the centre inward.
+ const arc = ctx.vertices.slice(0, 13);
+ const [a, b, c] = [arc[0], arc[6], arc[12]];
+ const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
+ const sq = (p: Point) => p.x * p.x + p.y * p.y;
+ const centre = {
+ x: (sq(a) * (b.y - c.y) + sq(b) * (c.y - a.y) + sq(c) * (a.y - b.y)) / d,
+ y: (sq(a) * (c.x - b.x) + sq(b) * (a.x - c.x) + sq(c) * (b.x - a.x)) / d,
+ };
+ const radii = arc.map((p) => Math.hypot(p.x - centre.x, p.y - centre.y));
+
+ // Every arc sample lies on that one circle...
+ for (const radius of radii) expect(radius).toBeCloseTo(radii[0], 4);
+ // ...centred on the pill's axis, at a radius near a true semicircle.
+ expect(centre.y).toBeCloseTo(R, 4);
+ expect(radii[0]).toBeGreaterThan(0.9 * R);
+ expect(radii[0]).toBeLessThanOrEqual(R + 1e-9);
+ });
+
+ it("holds constant curvature through the arc, unlike a superellipse", () => {
+ const profile = capCurvature(paint(WIDTH, HEIGHT));
+ const arcPart = profile.slice(0, 10);
+ for (const k of arcPart) expect(k).toBeCloseTo(arcPart[0], 3);
+ expect(arcPart[0]).toBeGreaterThan(0.9 / R);
+ });
+ });
+
+ describe("curvature easing (G2)", () => {
+ it("ramps curvature to zero instead of dropping it off a cliff", () => {
+ const eased = capCurvature(paint(WIDTH, HEIGHT));
+ const peak = Math.max(...eased);
+
+ // It arrives at the flat edge already flattened...
+ expect(peak).toBeGreaterThan(0);
+ expect(arrivalAngle(paint(WIDTH, HEIGHT))).toBeLessThan(3);
+ // ...and curvature sheds at a bounded rate. Measured per unit arc length,
+ // so it does not depend on how densely the outline happens to be sampled.
+ expect(curvatureGradient(paint(WIDTH, HEIGHT), R)).toBeLessThan(3);
+ });
+
+ it("falls monotonically once the easing starts", () => {
+ const eased = capCurvature(paint(WIDTH, HEIGHT));
+ const peak = eased.indexOf(Math.max(...eased));
+ for (let i = peak + 2; i < eased.length; i++) {
+ expect(eased[i]).toBeLessThanOrEqual(eased[i - 1] + 1e-9);
+ }
+ });
+
+ it("drops off a cliff at amt = 1, which is the plain stadium", () => {
+ const stadium = capCurvature(paint(WIDTH, HEIGHT, 1));
+ const peak = Math.max(...stadium);
+
+ // A bare semicircle holds 1 / R right up to the edge.
+ expect(peak).toBeCloseTo(1 / R, 3);
+ expect(stadium[stadium.length - 1]).toBeCloseTo(1 / R, 3);
+ // ...so unlike an eased cap it does not arrive flattened.
+ expect(arrivalAngle(paint(WIDTH, HEIGHT, 1))).toBeGreaterThan(
+ arrivalAngle(paint(WIDTH, HEIGHT, 3)),
+ );
+ });
+
+ it("eases more softly as the amount rises", () => {
+ const softness = [1, 2, 3].map((amt) => {
+ const ctx = paint(WIDTH, HEIGHT, amt);
+ // Where the flat edge begins: a softer ease starts turning sooner.
+ return ctx.vertices[junctionIndex(ctx)].x;
+ });
+ expect(softness[1]).toBeGreaterThan(softness[0]);
+ expect(softness[2]).toBeGreaterThan(softness[1]);
+ });
+ });
+
+ describe("fitting the easing to the box", () => {
+ it("eases harder when the pill is too stubby for the request", () => {
+ const roomy = paint(400, 60);
+ const stubby = paint(80, 60);
+ const easeSpan = (ctx: RecordingContext) => {
+ const j = junctionIndex(ctx);
+ // Angular span handed to the easing, via where the arc stops.
+ return ctx.vertices[j].x;
+ };
+ expect(easeSpan(stubby)).toBeLessThan(easeSpan(roomy));
+ });
+
+ it("renders a perfect square as a plain circle", () => {
+ const ctx = paint(100, 100);
+ const centre = { x: 50, y: 50 };
+ for (const p of ctx.vertices) {
+ expect(Math.hypot(p.x - centre.x, p.y - centre.y)).toBeCloseTo(50, 4);
+ }
+ });
+
+ it("never bulges outside the box, at any amount or ratio", () => {
+ for (const amt of [1, 2, 3, 6]) {
+ for (const [w, h] of [
+ [240, 60],
+ [70, 60],
+ [60, 60],
+ [60, 240],
+ [61, 60],
+ ]) {
+ for (const p of paint(w, h, amt).vertices) {
+ expect(p.x, `amt ${amt} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.x, `amt ${amt} @ ${w}x${h}`).toBeLessThanOrEqual(w + 1e-6);
+ expect(p.y, `amt ${amt} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.y, `amt ${amt} @ ${w}x${h}`).toBeLessThanOrEqual(h + 1e-6);
+ }
+ }
+ }
+ });
+ });
+
+ describe("the ease spread property", () => {
+ const arcOf = (ctx: RecordingContext) =>
+ circleThrough(ctx.vertices[0], ctx.vertices[6], ctx.vertices[12]);
+ const flatEdgeStart = (ctx: RecordingContext) => ctx.vertices[junctionIndex(ctx)].x;
+
+ it("stretches the transition along the flat edge", () => {
+ const reach = [0, 1, 2, 4].map((q) => flatEdgeStart(paint(WIDTH, HEIGHT, 2, q)));
+ for (let i = 1; i < reach.length; i++) {
+ expect(reach[i]).toBeGreaterThan(reach[i - 1]);
+ }
+ });
+
+ it("leaves the cap arc alone while doing so", () => {
+ // The whole point: a longer transition must not eat into the circle.
+ const caps = [0, 1, 2, 4].map((q) => arcOf(paint(WIDTH, HEIGHT, 2, q)));
+ for (const cap of caps) {
+ expect(cap.y).toBeCloseTo(R, 4);
+ // Radius barely moves, and stays close to a true semicircle...
+ expect(cap.r).toBeGreaterThan(0.9 * R);
+ }
+ expect(Math.abs(caps[caps.length - 1].r - caps[0].r)).toBeLessThan(0.05 * R);
+ });
+
+ it("reaches further than a high amount while keeping rounder ends", () => {
+ // The motivating case: amt 3 buys reach by spending the arc; a high
+ // a wide spread buys the same reach and keeps the arc.
+ const spendy = paint(WIDTH, HEIGHT, 3, 0);
+ const thrifty = paint(WIDTH, HEIGHT, 1.5, 4);
+
+ expect(flatEdgeStart(thrifty)).toBeGreaterThan(0.9 * flatEdgeStart(spendy));
+ expect(arcOf(thrifty).r).toBeGreaterThan(arcOf(spendy).r);
+ });
+
+ it("keeps curvature continuous at every spread at or above the clothoid", () => {
+ for (const q of [0, 1, 2, 6]) {
+ const profile = capCurvature(paint(WIDTH, HEIGHT, 2, q));
+ const peak = Math.max(...profile);
+
+ // Starts on the arc at 1 / cap radius, arrives flat, with no cliff.
+ expect(peak, `spread ${q}`).toBeGreaterThan(0);
+ expect(arrivalAngle(paint(WIDTH, HEIGHT, 2, q)), `spread ${q}`).toBeLessThan(3);
+ expect(curvatureGradient(paint(WIDTH, HEIGHT, 2, q), R), `spread ${q}`).toBeLessThan(3);
+ }
+ });
+
+ it("defaults to one step above the clothoid", () => {
+ expect(paint(WIDTH, HEIGHT, 2).vertices).toEqual(paint(WIDTH, HEIGHT, 2, 1).vertices);
+ // ...which is a real easing, not the clothoid itself.
+ expect(paint(WIDTH, HEIGHT, 2).vertices).not.toEqual(paint(WIDTH, HEIGHT, 2, 0).vertices);
+ });
+
+ it("defaults the amount to 2", () => {
+ const ctx = paint(WIDTH, HEIGHT);
+ expect(ctx.vertices).toEqual(paint(WIDTH, HEIGHT, 2, 1).vertices);
+ expect(ctx.vertices).not.toEqual(paint(WIDTH, HEIGHT, 1, 1).vertices);
+ });
+
+ it("honours spreads below the clothoid, corner and all", () => {
+ // Below 2 the transition is shorter and arrives ever more steeply, until
+ // at 0 it has no length and the arc meets the flat edge at a corner.
+ // Ugly, but it renders rather than being silently clamped away.
+ const angles = [-2, -1.5, -1, -0.5, 0].map((q) => arrivalAngle(paint(WIDTH, HEIGHT, 4, q)));
+ for (let i = 1; i < angles.length; i++) {
+ expect(angles[i], `spread step ${i}`).toBeLessThan(angles[i - 1]);
+ }
+ // The lowest spread is a genuine corner, not an easing.
+ expect(angles[0]).toBeGreaterThan(30);
+ });
+
+ it("stays finite and inside the box at every spread", () => {
+ for (const q of [-2, -1.75, -1.5, -1, -0.5, 0, 18]) {
+ for (const [w, h] of [
+ [600, 60],
+ [140, 60],
+ [60, 60],
+ [60, 600],
+ ]) {
+ const v = paint(w, h, 4, q).vertices;
+ expect(v.length, `spread ${q} @ ${w}x${h}`).toBeGreaterThan(3);
+ for (const p of v) {
+ const tag = `spread ${q} @ ${w}x${h}`;
+ expect(Number.isFinite(p.x) && Number.isFinite(p.y), tag).toBe(true);
+ expect(p.x, tag).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.x, tag).toBeLessThanOrEqual(w + 1e-6);
+ expect(p.y, tag).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.y, tag).toBeLessThanOrEqual(h + 1e-6);
+ }
+ }
+ }
+ });
+
+ it("still fits the box, and still collapses a square to a circle", () => {
+ for (const q of [2, 4, 8]) {
+ for (const [w, h] of [
+ [240, 60],
+ [70, 60],
+ [60, 240],
+ ]) {
+ for (const p of paint(w, h, 3, q).vertices) {
+ expect(p.x, `spread ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.x, `spread ${q} @ ${w}x${h}`).toBeLessThanOrEqual(w + 1e-6);
+ expect(p.y, `spread ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.y, `spread ${q} @ ${w}x${h}`).toBeLessThanOrEqual(h + 1e-6);
+ }
+ }
+ for (const p of paint(100, 100, 3, q).vertices) {
+ expect(Math.hypot(p.x - 50, p.y - 50), `spread ${q}`).toBeCloseTo(50, 4);
+ }
+ }
+ });
+ });
+
+ describe("fitting both controls together", () => {
+ // A pill too narrow for the requested easing has to give something up.
+ // Surrendering the amount alone drives the curvature rate up like 1 / beta,
+ // which is what makes a narrow pill look abruptly cornered.
+ const WIDTHS = [600, 300, 240, 180, 140, 110];
+
+ it("holds the curvature rate steady as the pill narrows", () => {
+ const rates = WIDTHS.map((w) => curvatureGradient(paint(w, HEIGHT, 4, 4), R));
+ const widest = rates[0];
+ for (let i = 0; i < rates.length; i++) {
+ expect(rates[i], `width ${WIDTHS[i]}`).toBeLessThan(1.3 * widest);
+ }
+ });
+
+ it("gives up spread as well as amount", () => {
+ // At 180 the requested easing does not fit, so both must come down.
+ const roomy = paint(600, HEIGHT, 4, 4);
+ const tight = paint(180, HEIGHT, 4, 4);
+
+ const capOf = (ctx: RecordingContext) =>
+ circleThrough(ctx.vertices[0], ctx.vertices[3], ctx.vertices[6]);
+ const flatEdgeStart = (ctx: RecordingContext) => ctx.vertices[junctionIndex(ctx)].x;
+
+ // The tight pill still eases — it has not collapsed to a stadium...
+ expect(flatEdgeStart(tight)).toBeGreaterThan(capOf(tight).r * 1.2);
+ // ...and it eases over less room than the roomy one.
+ expect(flatEdgeStart(tight)).toBeLessThan(flatEdgeStart(roomy));
+ });
+
+ it("keeps more of the amount than backing off the amount alone would", () => {
+ // Trading spread for amount is the whole point: the cap should stay
+ // meaningfully eased rather than snapping back to a bare semicircle.
+ const tight = paint(140, HEIGHT, 4, 4);
+ const profile = capCurvature(tight);
+ const peak = Math.max(...profile);
+
+ // A real ramp, not a cliff, at a width where beta-only fitting collapses.
+ expect(peak).toBeGreaterThan(0);
+ expect(arrivalAngle(tight)).toBeLessThan(3);
+ expect(profile.length).toBeGreaterThan(8);
+ });
+
+ it("leaves the default spread alone", () => {
+ // With the spread already at the clothoid there is nothing to trade,
+ // so narrowing may only reduce the amount.
+ for (const w of [600, 240, 140, 90]) {
+ const ctx = paint(w, HEIGHT, 4, 0);
+ for (const p of ctx.vertices) {
+ expect(p.x, `width ${w}`).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.x, `width ${w}`).toBeLessThanOrEqual(w + 1e-6);
+ }
+ }
+ });
+
+ it("still fits the box while trading the two off", () => {
+ for (const [amt, spread] of [
+ [4, 4],
+ [3, 6],
+ [2, 8],
+ ]) {
+ for (const [w, h] of [
+ [600, 60],
+ [140, 60],
+ [70, 60],
+ [61, 60],
+ [60, 60],
+ [60, 600],
+ ]) {
+ for (const p of paint(w, h, amt, spread).vertices) {
+ const tag = `amt ${amt} spread ${spread} @ ${w}x${h}`;
+ expect(p.x, tag).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.x, tag).toBeLessThanOrEqual(w + 1e-6);
+ expect(p.y, tag).toBeGreaterThanOrEqual(-1e-6);
+ expect(p.y, tag).toBeLessThanOrEqual(h + 1e-6);
+ }
+ }
+ }
+ });
+ });
+
+ describe("outline sampling", () => {
+ it("never lets a chord drift far from the curve", () => {
+ // Sagitta of each segment: ds * dphi / 8. Sampling that starves the
+ // start of the transition shows up here as a visible facet.
+ for (const [w, h, amt, spread] of [
+ [600, 60, 4, 4],
+ [240, 60, 4, 4],
+ [1200, 200, 4, 4],
+ [600, 60, 2, 0],
+ ]) {
+ const ctx = paint(w, h, amt, spread);
+ const v = ctx.vertices;
+ const cap = junctionIndex(ctx);
+ for (let i = 1; i < cap; i++) {
+ const d1 = { x: v[i].x - v[i - 1].x, y: v[i].y - v[i - 1].y };
+ const d2 = { x: v[i + 1].x - v[i].x, y: v[i + 1].y - v[i].y };
+ const l1 = Math.hypot(d1.x, d1.y);
+ if (l1 < 1e-9 || Math.hypot(d2.x, d2.y) < 1e-9) continue;
+ const turn = Math.abs(Math.atan2(d1.x * d2.y - d1.y * d2.x, d1.x * d2.x + d1.y * d2.y));
+ expect((l1 * turn) / 8, `${w}x${h} amt ${amt} spread ${spread}`).toBeLessThan(0.1);
+ }
+ }
+ });
+ });
+
+ describe("vertical pills", () => {
+ it("caps the short axis and keeps flat sides", () => {
+ const ctx = paint(60, 240);
+ const xs = ctx.vertices.map((p) => p.x);
+ const ys = ctx.vertices.map((p) => p.y);
+
+ expect(Math.min(...xs)).toBeCloseTo(0, 4);
+ expect(Math.max(...xs)).toBeCloseTo(60, 4);
+ expect(Math.min(...ys)).toBeCloseTo(0, 4);
+ expect(Math.max(...ys)).toBeCloseTo(240, 4);
+
+ const j = junctionIndex(ctx);
+ expect(Math.abs(ctx.vertices[j].x - ctx.vertices[j + 1].x)).toBeLessThan(1e-6);
+ });
+ });
+});
diff --git a/package/src/pill-shape.test.ts b/package/src/pill-shape.test.ts
new file mode 100644
index 0000000..3c238c7
--- /dev/null
+++ b/package/src/pill-shape.test.ts
@@ -0,0 +1,142 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { paintDef } from "./pill-shape.worklet";
+import {
+ CSS_NAMESPACE,
+ DEFAULT_PILL_AMT,
+ DEFAULT_PILL_EASE_SPREAD,
+ PILL_AMT_VAR_NAME,
+ PILL_BORDER_STYLE_VAR_NAME,
+ PILL_EASE_SPREAD_VAR_NAME,
+ PILL_STROKE_WIDTH_VAR_NAME,
+} from "./variants";
+
+const stylesheet = readFileSync(join(import.meta.dirname, "squircle-pill.css"), "utf-8");
+
+const registeredProperties = (css: string): string[] =>
+ [...css.matchAll(/@property\s+(--[\w-]+)/g)].map((m) => m[1]);
+
+const initialValueOf = (css: string, name: string): string | undefined =>
+ new RegExp(`@property\\s+${name}\\s*\\{[^}]*initial-value:\\s*([^;]+);`).exec(css)?.[1].trim();
+
+const inputProperties = (paintDef as unknown as { inputProperties: string[] }).inputProperties;
+const customInputs = inputProperties.filter((p) => p.startsWith("--"));
+
+describe("pill-shape worklet contract", () => {
+ it("namespaces every property it owns", () => {
+ // `--pill-*` is the kind of name a design system is likely to have taken.
+ // The prefix is fixed at build time, so the worklet, the plugins and the
+ // stylesheet all have to derive it from the same value.
+ const prefix = `--${CSS_NAMESPACE}-pill-`;
+ for (const name of customInputs) {
+ expect(name, `${name} is not namespaced`).toContain(prefix);
+ }
+ for (const name of registeredProperties(stylesheet)) {
+ expect(name, `${name} is not namespaced`).toContain(prefix);
+ }
+ });
+
+ it("reads exactly the properties it needs", () => {
+ expect(inputProperties).toEqual([
+ PILL_AMT_VAR_NAME,
+ PILL_EASE_SPREAD_VAR_NAME,
+ PILL_STROKE_WIDTH_VAR_NAME,
+ PILL_BORDER_STYLE_VAR_NAME,
+ ]);
+ });
+
+ describe("against squircle-pill.css", () => {
+ it("registers every shaping property the worklet reads", () => {
+ const registered = registeredProperties(stylesheet);
+ for (const name of [PILL_AMT_VAR_NAME, PILL_EASE_SPREAD_VAR_NAME]) {
+ expect(registered, `${name} must be registered`).toContain(name);
+ }
+ });
+
+ it("registers nothing the worklet does not read", () => {
+ // Registrations for properties the paint function ignores are dead
+ // plumbing: they read as configuration but change nothing.
+ for (const name of registeredProperties(stylesheet)) {
+ expect(customInputs, `${name} is registered but never read`).toContain(name);
+ }
+ });
+
+ it("leaves the stroke width unregistered on purpose", () => {
+ // The ring sets it inline on ::after. Registering it with an initial
+ // value of 0 would be harmless, but registering it as inherited would
+ // make every nested pill draw its parent's border.
+ expect(registeredProperties(stylesheet)).not.toContain(PILL_STROKE_WIDTH_VAR_NAME);
+ });
+
+ it("never paints the shape as a background", () => {
+ // Painting it as a background covers whatever background the element
+ // already had; masking keeps it and shapes it instead.
+ expect(stylesheet).not.toContain("background-image: paint(");
+ expect(stylesheet).toContain("mask-image: paint(pill-shape)");
+ });
+
+ it("starts the properties where the worklet's own fallbacks do", () => {
+ expect(initialValueOf(stylesheet, PILL_AMT_VAR_NAME)).toBe(String(DEFAULT_PILL_AMT));
+ expect(initialValueOf(stylesheet, PILL_EASE_SPREAD_VAR_NAME)).toBe(
+ String(DEFAULT_PILL_EASE_SPREAD),
+ );
+ });
+
+ it("assigns no custom property that nothing consumes", () => {
+ // Catches leftovers like `--pill-width: 100%` that outlived the paint
+ // function that once consumed them. A property is legitimate if the
+ // worklet reads it, or if the sheet itself feeds it into one that is
+ // read — which is how a framework's variable is bridged across.
+ const assigned = [...stylesheet.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1]);
+ const referenced = new Set([...stylesheet.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]));
+ for (const name of assigned) {
+ const consumed = customInputs.includes(name) || referenced.has(name);
+ expect(consumed, `${name} is assigned but nothing consumes it`).toBe(true);
+ }
+ });
+
+ it("bridges Tailwind's border style variable into the pill's own", () => {
+ // Where a utility exposes a variable, read it rather than asking for a
+ // second source of truth. Tailwind registers --tw-border-style as
+ // non-inheriting, so the explicit `inherit` is load-bearing.
+ expect(stylesheet).toContain("--tw-border-style: inherit");
+ expect(stylesheet).toContain(`${PILL_BORDER_STYLE_VAR_NAME}: var(--tw-border-style`);
+ });
+ });
+
+ it("defaults match the shared constants", () => {
+ // The worklet is deliberately import-free, so its own fallbacks are
+ // duplicated from variants.ts; this is what keeps them from drifting.
+ const paintWith = (props: Record | undefined) => {
+ const vertices: { x: number; y: number }[] = [];
+ const ctx = {
+ fillStyle: "",
+ beginPath() {},
+ fill() {},
+ closePath() {},
+ moveTo: (x: number, y: number) => vertices.push({ x, y }),
+ lineTo: (x: number, y: number) => vertices.push({ x, y }),
+ };
+ const lookup = {
+ get: (n: string) => (props?.[n] ? { toString: () => props[n] } : undefined),
+ };
+ new (paintDef as unknown as new () => {
+ paint(c: unknown, s: { width: number; height: number }, p: unknown): void;
+ })().paint(ctx, { width: 240, height: 60 }, lookup);
+ return vertices;
+ };
+
+ expect(paintWith(undefined)).toEqual(
+ paintWith({
+ [PILL_AMT_VAR_NAME]: String(DEFAULT_PILL_AMT),
+ [PILL_EASE_SPREAD_VAR_NAME]: String(DEFAULT_PILL_EASE_SPREAD),
+ }),
+ );
+ });
+});
diff --git a/package/src/pill-shape.worklet.ts b/package/src/pill-shape.worklet.ts
new file mode 100644
index 0000000..918abe5
--- /dev/null
+++ b/package/src/pill-shape.worklet.ts
@@ -0,0 +1,386 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+/**
+ * Prefix for the custom properties this worklet reads, inlined at build time
+ * from the same value `variants.ts` uses, so the two cannot disagree. See
+ * SQUIRCLE_CSS_NAMESPACE in vite.config.ts.
+ *
+ * This module stays import-free — a paint worklet is loaded as a standalone
+ * module script — so it takes the value from the define rather than importing
+ * the constants.
+ */
+declare const __SQUIRCLE_CSS_NAMESPACE__: string | undefined;
+
+const NS = `--${typeof __SQUIRCLE_CSS_NAMESPACE__ === "string" ? __SQUIRCLE_CSS_NAMESPACE__ : "squircle"}-pill`;
+const AMT_VAR = `${NS}-amt`;
+const EASE_SPREAD_VAR = `${NS}-ease-spread`;
+const STROKE_WIDTH_VAR = `${NS}-stroke-width`;
+const BORDER_STYLE_VAR = `${NS}-border-style`;
+
+interface PaintSize {
+ width: number;
+ height: number;
+}
+
+interface PaintProperties {
+ get(name: string): { toString(): string } | undefined;
+}
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+/**
+ * The pill amount sets how soft the easing is: how much of each cap is
+ * given over to the curvature transition, in units of 30 degrees. `1` keeps a
+ * bare semicircle (a plain stadium, what `border-radius: 9999px` already
+ * draws), the default `2` eases the last 30 degrees at each end, `3` eases 60.
+ *
+ * `1` meaning "circular" matches `--squircle-amt` elsewhere in this package.
+ * The request is only ever honoured up to what the element's aspect ratio can
+ * fit; see `fitEase`.
+ */
+// Kept in step with DEFAULT_PILL_AMT in variants.ts by a test; this module is
+// deliberately import-free so the worklet stays a standalone module script.
+const DEFAULT_AMOUNT = 2;
+const EASE_PER_AMOUNT = Math.PI / 6;
+const MAX_EASE = Math.PI / 3;
+
+/**
+ * The ease spread smooths the join into the flat edge: it draws the
+ * transition further along that edge without spending any more of the arc, so
+ * a softer join no longer costs you a rounder cap. Raising it lets
+ * the amount come down.
+ *
+ * `0` is a clothoid, where curvature falls linearly from the arc to the edge.
+ * The spread offsets the exponent that governs that fall,
+ * `k(t) = (1 / R) * (1 - t)^(q - 1)` with `q = spread + 2`, which makes the
+ * transition `q * beta * R` long. Any spread above -1 still starts at `1 / R`
+ * and ends at `0`, so G2 holds throughout.
+ *
+ * The default sits one step above the clothoid, which reads as a softer join
+ * without noticeably flattening the cap. Kept in step with
+ * DEFAULT_PILL_EASE_SPREAD in variants.ts by a test.
+ */
+const DEFAULT_SPREAD = 1;
+/** The exponent a spread of 0 means: curvature falling linearly, a clothoid. */
+const CLOTHOID_EXPONENT = 2;
+/**
+ * 0 is the sane floor for real use. Below it the curvature still reaches zero,
+ * but `dk/ds` diverges as it arrives; at -1 curvature never decays at all,
+ * leaving the same corner a bare stadium has; and at -2 the transition has no
+ * length. Those are still honoured so the effect can be seen. Lower would need
+ * a negative exponent, where `u ** q` blows up at `u = 0`.
+ */
+const MIN_SPREAD = -CLOTHOID_EXPONENT;
+
+/** Integration steps along one transition. Trapezoid error here is sub-pixel. */
+const EASE_STEPS = 512;
+
+/**
+ * How far the emitted polyline may sit from the true curve, in pixels.
+ *
+ * A chord spanning arc length `ds` while the curve turns `dphi` misses it by
+ * about `ds * dphi / 8`, so bounding that product places vertices densely where
+ * the outline turns hardest and sparsely down the near-straight tail. Sampling
+ * at a fixed rate instead starves the start of the transition, which is exactly
+ * where a wide spread piles up all of the curvature.
+ */
+const MAX_SAGITTA = 0.03;
+const MIN_SEGMENTS = 4;
+const MAX_SEGMENTS = 256;
+
+export const paintDef = class PillShape implements PaintWorklet {
+ static get inputProperties() {
+ // `color` is needed because a paint worklet cannot resolve the
+ // `currentColor` keyword itself — it has to be passed in as a property.
+ return [AMT_VAR, EASE_SPREAD_VAR, STROKE_WIDTH_VAR, BORDER_STYLE_VAR];
+ }
+
+ /**
+ * Dash pattern for the stroke, in multiples of its width, for
+ * the border style. `none` and `hidden` suppress the ring entirely,
+ * matching what those keywords do to a real border.
+ *
+ * The worklet keeps its own vocabulary rather than reading a framework's
+ * variables directly; the Tailwind layer maps `--tw-border-style` onto this.
+ */
+ resolveDash(props: PaintProperties | undefined, width: number): number[] | null {
+ const style = props?.get(BORDER_STYLE_VAR)?.toString().trim();
+ if (style === "none" || style === "hidden") return null;
+ if (style === "dashed") return [width * 3, width * 2];
+ if (style === "dotted") return [width, width * 2];
+ return [];
+ }
+
+ /**
+ * Stroke width, in pixels. Zero fills the shape; anything larger draws an
+ * inset band of exactly that width, hugging the inside of the outline.
+ */
+ resolveStrokeWidth(props?: PaintProperties): number {
+ const raw = Number.parseFloat(props?.get(STROKE_WIDTH_VAR)?.toString() ?? "");
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
+ }
+
+ /** The requested easing angle, in radians, before it is fitted to the box. */
+ resolveEase(props?: PaintProperties): number {
+ const raw = Number.parseFloat(props?.get(AMT_VAR)?.toString() ?? "");
+ const amt = Number.isFinite(raw) ? raw : DEFAULT_AMOUNT;
+ return Math.min(Math.max(amt - 1, 0) * EASE_PER_AMOUNT, MAX_EASE);
+ }
+
+ /**
+ * How far the join is spread along the flat edge, as the curvature exponent
+ * it offsets; see `DEFAULT_SPREAD`.
+ */
+ resolveExponent(props?: PaintProperties): number {
+ const raw = Number.parseFloat(props?.get(EASE_SPREAD_VAR)?.toString() ?? "");
+ const spread = Number.isFinite(raw) ? raw : DEFAULT_SPREAD;
+ return Math.max(spread, MIN_SPREAD) + CLOTHOID_EXPONENT;
+ }
+
+ /**
+ * Running integrals of `cos(b * u^q)` and `sin(b * u^q)` over `[0, t]`.
+ *
+ * At `q = 2` these are the Fresnel integrals describing a clothoid — the
+ * curve whose curvature falls linearly with arc length, and so the curve that
+ * joins a circular arc to a straight line with no jump in curvature. Other
+ * exponents keep both endpoint curvatures and just redistribute the fall. The
+ * same quadrature sizes the cap and places the points, so the transition
+ * lands exactly on the straight edge.
+ */
+ fresnel(beta: number, q: number): { cos: number[]; sin: number[] } {
+ const cos = [0];
+ const sin = [0];
+ const h = 1 / EASE_STEPS;
+ let c = 0;
+ let s = 0;
+
+ for (let i = 1; i <= EASE_STEPS; i++) {
+ // Integrating in tau, where u = 1 - tau.
+ const u0 = (1 - (i - 1) * h) ** q;
+ const u1 = (1 - i * h) ** q;
+ c += ((Math.cos(beta * u0) + Math.cos(beta * u1)) / 2) * h;
+ s += ((Math.sin(beta * u0) + Math.sin(beta * u1)) / 2) * h;
+ cos.push(c);
+ sin.push(s);
+ }
+
+ return { cos, sin };
+ }
+
+ /**
+ * Cap radius, and the coordinate where the easing meets the flat edge, for a
+ * cap of half-height `r` easing through `beta`.
+ *
+ * The cap still has to span the full height, so the arc's rise
+ * (`R cos beta`) plus the transition's rise (`q R beta * S`) must equal `r`.
+ */
+ capMetrics(
+ r: number,
+ beta: number,
+ q: number,
+ fresnel: { cos: number[]; sin: number[] },
+ ): { radius: number; junction: number } {
+ const totalCos = fresnel.cos[EASE_STEPS];
+ const totalSin = fresnel.sin[EASE_STEPS];
+ const radius = r / (Math.cos(beta) + q * beta * totalSin);
+ const junction = radius * (1 - Math.sin(beta) + q * beta * totalCos);
+ return { radius, junction };
+ }
+
+ /**
+ * The softest easing that still fits, backing off the amount and the spread
+ * together.
+ *
+ * What reads as a smooth transition is the rate curvature changes,
+ * `|dk/ds| * R^2 = (q - 1) / (q * beta)`. Surrendering beta alone sends that
+ * rate up like `1 / beta`, so a pill too narrow for the requested easing ends
+ * up looking abruptly cornered even though it is still formally G2. Holding
+ * the rate fixed instead pins the exponent to whatever beta survives:
+ *
+ * q = 1 / (1 - rate * beta)
+ *
+ * which returns the requested exponent at the requested beta and eases down
+ * towards the plain clothoid as the room runs out. Once it bottoms out there
+ * the rate does climb, on the way to the bare semicircle a square has no
+ * choice but to be.
+ */
+ fitEasing(
+ r: number,
+ half: number,
+ wantedBeta: number,
+ wantedExponent: number,
+ ): { beta: number; exponent: number } {
+ if (wantedBeta <= 0) return { beta: 0, exponent: wantedExponent };
+
+ // There is only something to trade above the clothoid. At or below it the
+ // requested exponent is passed through and the amount absorbs the
+ // shortfall, which also keeps the rate away from the 0 and 1 singularities.
+ const tradeable = wantedExponent > CLOTHOID_EXPONENT;
+ const rate = tradeable ? (wantedExponent - 1) / (wantedExponent * wantedBeta) : 0;
+ // rate * beta <= rate * wantedBeta = (q - 1) / q < 1, so the denominator
+ // stays positive.
+ const exponentFor = (beta: number): number =>
+ tradeable
+ ? Math.min(Math.max(1 / (1 - rate * beta), CLOTHOID_EXPONENT), wantedExponent)
+ : wantedExponent;
+
+ const fits = (beta: number): boolean => {
+ const exponent = exponentFor(beta);
+ return this.capMetrics(r, beta, exponent, this.fresnel(beta, exponent)).junction <= half;
+ };
+
+ if (fits(wantedBeta)) return { beta: wantedBeta, exponent: wantedExponent };
+
+ let low = 0;
+ let high = wantedBeta;
+ for (let i = 0; i < 24; i++) {
+ const mid = (low + high) / 2;
+ if (fits(mid)) low = mid;
+ else high = mid;
+ }
+ return { beta: low, exponent: exponentFor(low) };
+ }
+
+ /**
+ * One quadrant of the outline: from the leftmost point of the cap, round the
+ * arc, and through the easing to where it becomes the flat top edge.
+ */
+ quadrant(r: number, beta: number, q: number): Point[] {
+ const fresnel = this.fresnel(beta, q);
+ const { radius } = this.capMetrics(r, beta, q, fresnel);
+ const points: Point[] = [];
+
+ // Circular cap, from the leftmost point to where the easing takes over.
+ // Constant radius, so a constant step keeps the chord error in bounds.
+ const sweep = Math.PI / 2 - beta;
+ const arcSteps = Math.min(
+ Math.max(Math.ceil(sweep / Math.sqrt((8 * MAX_SAGITTA) / radius)), MIN_SEGMENTS),
+ MAX_SEGMENTS,
+ );
+ for (let i = 0; i <= arcSteps; i++) {
+ const theta = Math.PI + (sweep * i) / arcSteps;
+ points.push({ x: radius + radius * Math.cos(theta), y: r + radius * Math.sin(theta) });
+ }
+
+ if (beta <= 0) return points;
+
+ // The lowest spread gives the transition no length at all: the arc alone
+ // spans the height and meets the flat edge at a corner.
+ if (q <= 0) return points;
+
+ // Curvature ramps from 1 / radius down to 0 across the transition, which
+ // the exponent makes q * beta * radius long. Vertices land where the chord
+ // would otherwise drift off the curve.
+ const length = q * radius * beta;
+ const start = points[points.length - 1];
+ const at = (k: number): Point => ({
+ x: start.x + length * fresnel.cos[k],
+ y: start.y - length * fresnel.sin[k],
+ });
+ const turnTo = (t: number): number => beta * (1 - (1 - t) ** q);
+
+ let anchor = 0;
+ for (let k = 1; k < EASE_STEPS; k++) {
+ const span = length * ((k - anchor) / EASE_STEPS);
+ const turn = turnTo(k / EASE_STEPS) - turnTo(anchor / EASE_STEPS);
+ if (span * turn >= 8 * MAX_SAGITTA) {
+ points.push(at(k));
+ anchor = k;
+ }
+ }
+ points.push(at(EASE_STEPS));
+
+ return points;
+ }
+
+ paint(ctx: CanvasRenderingContext2D, size: PaintSize, props?: PaintProperties): void {
+ const { width, height } = size;
+ if (width <= 0 || height <= 0) return;
+
+ /*
+ * Opaque, always. The shape is consumed as a mask, where only the alpha
+ * channel counts, and the element's own background supplies the colour.
+ * Reading `color` here would mean `color: transparent` erased the element.
+ */
+ const stroke = this.resolveStrokeWidth(props);
+ const dash = stroke > 0 ? this.resolveDash(props, stroke) : [];
+ // `border-style: none` leaves nothing to draw.
+ if (dash === null) return;
+
+ if (stroke > 0) {
+ ctx.strokeStyle = "#000";
+ // Doubled, because the half outside the outline is clipped away below.
+ ctx.lineWidth = stroke * 2;
+ if (dash.length > 0) ctx.setLineDash(dash);
+ } else {
+ ctx.fillStyle = "#000";
+ }
+ ctx.beginPath();
+
+ // Work along the pill's long axis, then transpose for a vertical pill.
+ const vertical = height > width;
+ const long = vertical ? height : width;
+ const short = vertical ? width : height;
+ const r = short / 2;
+ const { beta, exponent } = this.fitEasing(
+ r,
+ long / 2,
+ this.resolveEase(props),
+ this.resolveExponent(props),
+ );
+
+ const outline = this.outline(long, short, this.quadrant(r, beta, exponent));
+ for (let i = 0; i < outline.length; i++) {
+ const p = outline[i];
+ const x = vertical ? p.y : p.x;
+ const y = vertical ? p.x : p.y;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+
+ ctx.closePath();
+
+ if (stroke > 0) {
+ /*
+ * Clip to the outline before stroking, so the band sits wholly inside it.
+ * A centred stroke would spill half its width past the outline, and that
+ * half is cut off by the edge of the paint canvas rather than by the
+ * shape — which trims it on the flat edges, where the outline runs along
+ * the canvas boundary, but not through the caps, where the outline curves
+ * inward. The ring would come out flattened and uneven.
+ */
+ ctx.clip();
+ ctx.stroke();
+ } else {
+ ctx.fill();
+ }
+ }
+
+ /** Mirror one quadrant into the full outline, walking clockwise. */
+ outline(long: number, short: number, quadrant: Point[]): Point[] {
+ const reversed = [...quadrant].reverse();
+ return [
+ // Leftmost point, up through the easing into the flat top edge.
+ ...quadrant,
+ // Mirrored in x: back down to the rightmost point.
+ ...reversed.map((p) => ({ x: long - p.x, y: p.y })),
+ // Then the bottom half, mirrored in y.
+ ...quadrant.map((p) => ({ x: long - p.x, y: short - p.y })),
+ ...reversed.map((p) => ({ x: p.x, y: short - p.y })),
+ ];
+ }
+};
+
+// `registerPaint` only exists inside a paint worklet global scope. Guarding the
+// call keeps this module importable from tests and bundlers.
+declare const registerPaint: ((name: string, def: unknown) => void) | undefined;
+
+if (typeof registerPaint !== "undefined") {
+ registerPaint("pill-shape", paintDef);
+}
diff --git a/package/src/squircle-pill.css b/package/src/squircle-pill.css
new file mode 100644
index 0000000..f86be7f
--- /dev/null
+++ b/package/src/squircle-pill.css
@@ -0,0 +1,95 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+/* ── Register paint worklet input properties ──────────────────
+ * The worklet reads these by name through `inputProperties`.
+ * Registering them makes the values typed and animatable and gives
+ * them initial values matching the worklet's own fallbacks.
+ *
+ * Registration is not conditional on paint support: a property is
+ * worth registering either way, and `@supports` says nothing about
+ * whether the worklet actually loaded.
+ * ──────────────────────────────────────────────────────────── */
+
+@property --squircle-pill-amt {
+ syntax: "";
+ initial-value: 2;
+ inherits: false;
+}
+
+/* How far the easing is spread along the flat edge. Offsets the curvature
+ * exponent, so 0 is a plain clothoid; see the pill-shape worklet. */
+@property --squircle-pill-ease-spread {
+ syntax: "";
+ initial-value: 1;
+ inherits: false;
+}
+
+/* ── Base pill shape utility ──────────────────────────────────
+ * Hands the element to the pill-shape paint worklet, which derives
+ * the whole silhouette from the element's own size. Shape it with
+ * --squircle-pill-amt and --squircle-pill-ease-spread.
+ *
+ * Attribute-based so it works without Tailwind; the plugin's
+ * `squircle-pill` utilities carry the same rules on the class.
+ * ──────────────────────────────────────────────────────────── */
+
+@supports (mask-image: paint(pill-shape)) {
+ [data-squircle-pill] {
+ -webkit-mask-image: paint(pill-shape);
+ mask-image: paint(pill-shape);
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ mask-mode: alpha;
+ /* The worklet only runs where there is an area to paint. */
+ min-width: 1px;
+ min-height: 1px;
+ position: relative;
+ }
+
+ /* A CSS border cannot follow this shape, so the worklet draws one in stroke
+ * mode, which lays an inset band along the inside of the outline. */
+ [data-squircle-pill]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: var(--squircle-pill-border-color, transparent);
+ --squircle-pill-stroke-width: var(--squircle-pill-border-width, 0px);
+ /* Read Tailwind's own variable where it exposes one: border-dashed and
+ * friends set --tw-border-style. It is registered as non-inheriting, hence
+ * the explicit inherit. */
+ --tw-border-style: inherit;
+ --squircle-pill-border-style: var(--tw-border-style, solid);
+ -webkit-mask-image: paint(pill-shape);
+ mask-image: paint(pill-shape);
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ mask-mode: alpha;
+ }
+}
+
+/* ── Fallback for browsers without Paint Worklet support ──────
+ * A plain fully-rounded rectangle, and nothing else. A superellipse
+ * corner reads as more wrong than a stadium here: on a pill the cap
+ * is the whole shape, so reshaping it changes the silhouette rather
+ * than just softening a corner.
+ *
+ * Gated only on the worklet being absent, so browsers with neither
+ * feature still get a pill. The radius matches FULL_RADIUS in
+ * variants.ts, and so Tailwind's own `rounded-full`. Native border,
+ * outline and box-shadow all work on this branch, because
+ * border-radius is a shape the platform understands.
+ * ──────────────────────────────────────────────────────────── */
+
+@supports not (mask-image: paint(pill-shape)) {
+ [data-squircle-pill] {
+ border-radius: calc(infinity * 1px);
+ }
+}
diff --git a/package/src/tailwind-pill-border.test.ts b/package/src/tailwind-pill-border.test.ts
new file mode 100644
index 0000000..679fff5
--- /dev/null
+++ b/package/src/tailwind-pill-border.test.ts
@@ -0,0 +1,71 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import { describe, expect, it } from "vitest";
+import { createCompiler } from "./test-utils";
+import { PILL_BORDER_COLOR_VAR_NAME, PILL_BORDER_WIDTH_VAR_NAME } from "./variants";
+
+const { compilePlugin } = createCompiler(import.meta.dirname);
+const compileBorder = (candidates: string[], block = "") =>
+ compilePlugin(candidates, block, "./tailwind-pill-border.ts");
+
+describe("tailwind-pill-border.ts", () => {
+ describe("extends rather than replaces", () => {
+ it("leaves Tailwind's own border-width output intact", async () => {
+ const css = await compileBorder(["border-2"]);
+ expect(css).toContain("border-width: 2px");
+ expect(css).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 2px`);
+ });
+
+ it("leaves Tailwind's own border-color output intact", async () => {
+ const css = await compileBorder(["border-red-500"]);
+ expect(css).toContain("border-color: var(--color-red-500)");
+ expect(css).toContain(`${PILL_BORDER_COLOR_VAR_NAME}:`);
+ });
+
+ it("does not disturb utilities it has no values for", async () => {
+ // `border-dashed` is a style, and this plugin registers only widths and
+ // colours, so it must fall through to Tailwind untouched.
+ const css = await compileBorder(["border-dashed"]);
+ expect(css).toContain("--tw-border-style: dashed");
+ expect(css).not.toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: dashed`);
+ expect(css).not.toContain(`${PILL_BORDER_COLOR_VAR_NAME}: dashed`);
+ });
+
+ it("covers arbitrary values without enumerating them", async () => {
+ const css = await compileBorder(["border-[3px]"]);
+ expect(css).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 3px`);
+ });
+ });
+
+ describe("scoping", () => {
+ it("only touches elements that are pills", async () => {
+ // A border utility has to keep behaving normally everywhere else.
+ const css = await compileBorder(["border-2", "border-red-500"]);
+ expect(css).toContain("&:is(.squircle-pill)");
+ // The pill vars never appear unscoped.
+ for (const line of css.split("\n")) {
+ if (
+ line.includes(PILL_BORDER_WIDTH_VAR_NAME) ||
+ line.includes(PILL_BORDER_COLOR_VAR_NAME)
+ ) {
+ expect(css.indexOf("&:is(.squircle-pill)")).toBeLessThan(css.indexOf(line));
+ }
+ }
+ });
+
+ it("suppresses the real border's paint on pills only", async () => {
+ // Under the mask a real border is a rectangle clipped to the pill.
+ const css = await compileBorder(["border-red-500"]);
+ const scoped = css.slice(css.indexOf("&:is(.squircle-pill)"));
+ expect(scoped).toContain("border-color: transparent");
+ });
+
+ it("honours a custom prefix", async () => {
+ const css = await compileBorder(["border-2"], 'prefix: "pillbox";');
+ expect(css).toContain("&:is(.pillbox)");
+ });
+ });
+});
diff --git a/package/src/tailwind-pill-border.ts b/package/src/tailwind-pill-border.ts
new file mode 100644
index 0000000..237da35
--- /dev/null
+++ b/package/src/tailwind-pill-border.ts
@@ -0,0 +1,76 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import plugin from "tailwindcss/plugin";
+import { PILL_BORDER_COLOR_VAR_NAME, PILL_BORDER_WIDTH_VAR_NAME } from "./variants";
+
+export interface SquirclePillBorderPluginOptions {
+ /** Class name of the pill utility these borders apply to (default: "squircle-pill") */
+ prefix?: string;
+}
+
+/**
+ * Teaches Tailwind's own `border-*` utilities to drive a pill's drawn border.
+ *
+ * A pill is shaped by a mask, and a mask erases everything outside the shape,
+ * so a real CSS border survives only as rectangle fragments. The shape's border
+ * has to be drawn by the worklet instead, from `--pill-border-width` and
+ * `--pill-border-color` — which would otherwise mean a second way of spelling
+ * something Tailwind already spells.
+ *
+ * This registers those same utility names again. Tailwind does not treat that
+ * as an override: it emits a second rule alongside its own, so `border-2` keeps
+ * setting `border-width` and additionally sets `--pill-border-width`. Nothing
+ * here reimplements what a border utility means, which is what keeps it from
+ * breaking when those utilities change.
+ *
+ * It also means this plugin never has to decide whether `border-red-500` is a
+ * width or a colour: a functional utility only matches when the value resolves
+ * against the values given to it, so widths and colours can be registered
+ * separately and anything unrecognised falls through to Tailwind untouched.
+ *
+ * `border-dashed` and friends need no help — they set `--tw-border-style`, and
+ * the pill utility reads that variable directly.
+ */
+const squirclePillBorder: ReturnType> =
+ plugin.withOptions(
+ (options = {}) =>
+ ({ matchUtilities, theme }) => {
+ const prefix = options.prefix ?? "squircle-pill";
+
+ /*
+ * Scoped to pills, so a border utility keeps behaving normally
+ * everywhere else. `:is()` also lifts specificity above a bare utility
+ * class, which is what lets the pill suppress the real border's paint
+ * without depending on which rule Tailwind happens to emit last.
+ */
+ const onPill = (declarations: Record) => ({
+ [`&:is(.${prefix})`]: declarations,
+ });
+
+ matchUtilities(
+ {
+ border: (value: string) => onPill({ [PILL_BORDER_WIDTH_VAR_NAME]: value }),
+ },
+ { values: theme("borderWidth") ?? {} },
+ );
+
+ matchUtilities(
+ {
+ border: (value: string) =>
+ onPill({
+ [PILL_BORDER_COLOR_VAR_NAME]: value,
+ // The real border must not paint: under the mask it is a
+ // rectangle clipped to the pill. Its width still contributes to
+ // layout, which correctly reserves room for the drawn ring.
+ "border-color": "transparent",
+ }),
+ },
+ { values: theme("colors") ?? {}, type: "color" },
+ );
+ },
+ );
+
+export default squirclePillBorder;
diff --git a/package/src/tailwind-pill.test.ts b/package/src/tailwind-pill.test.ts
new file mode 100644
index 0000000..4e345e6
--- /dev/null
+++ b/package/src/tailwind-pill.test.ts
@@ -0,0 +1,109 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import { describe, expect, it } from "vitest";
+import { createCompiler } from "./test-utils";
+import {
+ FULL_RADIUS,
+ PILL_AMT_VAR_NAME,
+ PILL_BORDER_COLOR_VAR_NAME,
+ PILL_BORDER_WIDTH_VAR_NAME,
+ PILL_EASE_SPREAD_VAR_NAME,
+ PILL_STROKE_WIDTH_VAR_NAME,
+} from "./variants";
+
+const { compilePlugin, compilePluginAll } = createCompiler(import.meta.dirname);
+const compilePill = (candidates: string[], block = "") =>
+ compilePlugin(candidates, block, "./tailwind-pill.ts");
+const compilePillAll = (candidates: string[], block = "") =>
+ compilePluginAll(candidates, block, "./tailwind-pill.ts");
+
+describe("tailwind-pill.ts utilities", () => {
+ it("masks the element to the pill where the worklet is available", async () => {
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).toContain("@supports (mask-image: paint(pill-shape))");
+ expect(css).toContain("mask-image: paint(pill-shape)");
+ expect(css).toContain("-webkit-mask-image: paint(pill-shape)");
+ });
+
+ it("never paints the shape as a background", async () => {
+ // A painted background covers whatever background the element already had.
+ // Masking keeps the element's own background — colour, gradient, image —
+ // and shapes that instead.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).not.toContain("background-image: paint(");
+ });
+
+ it("draws a border the shape can actually follow", async () => {
+ // A CSS border would be a rectangle clipped to the pill, so the worklet
+ // strokes one on ::after instead.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).toContain("&::after");
+ expect(css).toContain(`${PILL_STROKE_WIDTH_VAR_NAME}: var(${PILL_BORDER_WIDTH_VAR_NAME}, 0px)`);
+ expect(css).toContain(`background: var(${PILL_BORDER_COLOR_VAR_NAME}, transparent)`);
+ });
+
+ describe("fallback without the paint worklet", () => {
+ it("is a plain fully-rounded rectangle", async () => {
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).toContain("@supports not (mask-image: paint(pill-shape))");
+ // The same radius the `-full` utilities use, matching `rounded-full`.
+ expect(css).toContain(`border-radius: ${FULL_RADIUS}`);
+ });
+
+ it("never reshapes the corner", async () => {
+ // On a pill the cap is the whole shape, so a superellipse changes the
+ // silhouette rather than softening a corner — it reads worse than a
+ // plain stadium.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).not.toContain("corner-shape");
+ expect(css).not.toContain("superellipse");
+ });
+
+ it("does not require corner-shape support to apply", async () => {
+ // Gating on corner-shape left browsers with neither feature square.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).not.toContain("@supports (corner-shape");
+ });
+
+ it("never falls back to a percentage radius", async () => {
+ // `50%` is an ellipse on any non-square element.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).not.toContain("border-radius: 50%");
+ });
+ });
+
+ describe("standing on its own", () => {
+ it("emits no declaration that pretends to set an attribute", async () => {
+ // A stylesheet cannot set an attribute, so `data-squircle-pill: ;` was
+ // inert: it could never make squircle-pill.css's `[data-squircle-pill]`
+ // rules match an element that only carries the class.
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).not.toContain("data-squircle-pill");
+ });
+
+ it("registers the properties the worklet reads", async () => {
+ // Without this the class alone would leave them unregistered, so they
+ // could not be typed or animated.
+ const css = await compilePillAll(["squircle-pill"]);
+ expect(css).toContain(`@property ${PILL_AMT_VAR_NAME}`);
+ expect(css).toContain(`@property ${PILL_EASE_SPREAD_VAR_NAME}`);
+ expect(css).toContain("initial-value: 2");
+ expect(css).toContain("initial-value: 1");
+ });
+
+ it("gives the worklet an area to paint", async () => {
+ const css = await compilePill(["squircle-pill"]);
+ expect(css).toContain("min-width: 1px");
+ expect(css).toContain("min-height: 1px");
+ });
+ });
+
+ it("honours a custom prefix", async () => {
+ const css = await compilePill(["pillbox"], 'prefix: "pillbox";');
+ expect(css).toContain(".pillbox");
+ expect(css).toContain(`border-radius: ${FULL_RADIUS}`);
+ });
+});
diff --git a/package/src/tailwind-pill.ts b/package/src/tailwind-pill.ts
new file mode 100644
index 0000000..43e01de
--- /dev/null
+++ b/package/src/tailwind-pill.ts
@@ -0,0 +1,139 @@
+/*!
+ * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink
+ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle
+ */
+
+import plugin from "tailwindcss/plugin";
+import {
+ DEFAULT_PILL_AMT,
+ DEFAULT_PILL_EASE_SPREAD,
+ FULL_RADIUS,
+ PILL_AMT_VAR_NAME,
+ PILL_BORDER_COLOR_VAR_NAME,
+ PILL_BORDER_STYLE_FALLBACK,
+ PILL_BORDER_STYLE_VAR_NAME,
+ PILL_BORDER_WIDTH_VAR_NAME,
+ PILL_EASE_SPREAD_VAR_NAME,
+ PILL_STROKE_WIDTH_VAR_NAME,
+ variantEntries,
+} from "./variants";
+
+export interface SquirclePillPluginOptions {
+ /** Class name prefix for utilities (default: "squircle-pill") */
+ prefix?: string;
+}
+
+const squirclePill: ReturnType> =
+ plugin.withOptions((options = {}) => ({ addBase, addUtilities }) => {
+ const prefix = options.prefix ?? "squircle-pill";
+
+ /*
+ * Register the properties the worklet reads. The utility has to carry
+ * these itself rather than lean on squircle-pill.css: that stylesheet
+ * hangs its rules off `[data-squircle-pill]`, and no stylesheet can set
+ * an attribute, so a class could never pull them in. Registering also
+ * makes the values typed and animatable, with initial values matching
+ * the worklet's own fallbacks.
+ */
+ addBase({
+ [`@property ${PILL_AMT_VAR_NAME}`]: {
+ syntax: '""',
+ "initial-value": String(DEFAULT_PILL_AMT),
+ inherits: "false",
+ },
+ [`@property ${PILL_EASE_SPREAD_VAR_NAME}`]: {
+ syntax: '""',
+ "initial-value": String(DEFAULT_PILL_EASE_SPREAD),
+ inherits: "false",
+ },
+ });
+
+ /*
+ * The shape is applied as a mask, not painted as a background, so the
+ * element keeps whatever background it already has — a colour, a gradient,
+ * an image — and that background is what gets pill-shaped.
+ *
+ * A mask erases everything outside the shape, which no `border`, `outline`
+ * or `box-shadow` can survive: a border would be a rectangle clipped to the
+ * pill, and the other two are painted outside the box and vanish entirely.
+ * `filter` is applied before the mask, so even `drop-shadow` set here would
+ * shadow the unmasked rectangle and then be clipped away.
+ *
+ * A border is therefore drawn, on `::after`, by the same worklet in stroke
+ * mode, which lays an inset band along the inside of the outline. For a
+ * shadow, put `filter: drop-shadow(...)` on a wrapper, where it applies to
+ * the already-masked result.
+ */
+ const mask = {
+ "-webkit-mask-image": "paint(pill-shape)",
+ "mask-image": "paint(pill-shape)",
+ "-webkit-mask-size": "100% 100%",
+ "mask-size": "100% 100%",
+ "-webkit-mask-repeat": "no-repeat",
+ "mask-repeat": "no-repeat",
+ "mask-mode": "alpha",
+ };
+
+ const pillBase = {
+ "@supports (mask-image: paint(pill-shape))": {
+ ...mask,
+ // The worklet only runs where there is an area to paint.
+ "min-width": "1px",
+ "min-height": "1px",
+ position: "relative",
+
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: "0",
+ "pointer-events": "none",
+ background: `var(${PILL_BORDER_COLOR_VAR_NAME}, transparent)`,
+ [PILL_STROKE_WIDTH_VAR_NAME]: `var(${PILL_BORDER_WIDTH_VAR_NAME}, 0px)`,
+ /*
+ * Where a Tailwind border utility exposes a variable, read it rather
+ * than asking for a second source of truth: `border-dashed` and
+ * friends set `--tw-border-style`, so the drawn ring picks up the
+ * style straight from it. Tailwind registers that property as
+ * non-inheriting, hence the explicit `inherit` to pull the element's
+ * value onto the pseudo. Width and colour have no such variable —
+ * those utilities set `border-width`/`border-color` directly — so
+ * they still come from the pill's own properties.
+ */
+ "--tw-border-style": "inherit",
+ [PILL_BORDER_STYLE_VAR_NAME]: `var(--tw-border-style, ${PILL_BORDER_STYLE_FALLBACK})`,
+ ...mask,
+ },
+ },
+ /*
+ * Without the paint worklet, fall back to a plain fully-rounded rectangle
+ * and nothing else. A superellipse corner reads as more wrong than a
+ * stadium here: the cap is the whole shape, so reshaping it changes the
+ * silhouette rather than just softening a corner.
+ *
+ * Gated only on the worklet being absent, so browsers with neither
+ * feature still get a pill. The radius is the same `FULL_RADIUS` the
+ * `-full` utilities use, matching Tailwind's `rounded-full`. Native
+ * border, outline and box-shadow all work on this branch, because
+ * border-radius is a shape the platform understands.
+ */
+ "@supports not (mask-image: paint(pill-shape))": {
+ "border-radius": FULL_RADIUS,
+ },
+ };
+
+ addUtilities({
+ [`.${prefix}`]: pillBase,
+ });
+
+ // Side-specific variants
+ for (const [suffix] of variantEntries()) {
+ if (suffix) {
+ // Only add side-specific variants, not the base
+ addUtilities({
+ [`.${prefix}-${suffix}`]: { ...pillBase },
+ });
+ }
+ }
+ });
+
+export default squirclePill;
diff --git a/package/src/tailwind.test.ts b/package/src/tailwind.test.ts
index 7c9d48d..6e90b6a 100644
--- a/package/src/tailwind.test.ts
+++ b/package/src/tailwind.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createCompiler, VARIANTS } from "./test-utils";
+import { DEFAULT_AMOUNT_VAR_NAME } from "./variants";
const { compilePlugin } = createCompiler(import.meta.dirname);
@@ -151,6 +152,19 @@ describe("plugin.ts custom options", () => {
expect(css).toContain("--squircle-amt: 3");
});
+ it("deprecated amt-var still wins over the namespace default", async () => {
+ // The option predates the namespace and is deprecated, but overriding it
+ // must keep working — and must beat whatever the namespace resolves to.
+ const css = await compilePlugin(["squircle-md"], "amt-var: --se-amt;");
+ expect(css).toContain("--se-amt");
+ expect(css).not.toContain(DEFAULT_AMOUNT_VAR_NAME);
+ });
+
+ it("falls back to the namespaced name when the option is absent", async () => {
+ const css = await compilePlugin(["squircle-md"]);
+ expect(css).toContain(DEFAULT_AMOUNT_VAR_NAME);
+ });
+
it("custom amt-var changes the CSS variable name", async () => {
const css = await compilePlugin(["squircle-md"], "amt-var: --se-amt;");
expect(css).toContain("var(--se-amt, 2)");
diff --git a/package/src/tailwind.ts b/package/src/tailwind.ts
index cf9bb86..79c0036 100644
--- a/package/src/tailwind.ts
+++ b/package/src/tailwind.ts
@@ -18,12 +18,27 @@ import {
export interface SquirclePluginOptions {
/** CSS custom property name for the superellipse amount (default: "--squircle-amt") */
+ /**
+ * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at
+ * build time, which renames every property this package owns together. This
+ * option still works and still wins, but it only ever reached the utilities
+ * this plugin emits — never the paint worklet, which names the properties it
+ * reads in a static `inputProperties` list.
+ */
amtVar?: string;
- /** @plugin CSS alias for amtVar */
+ /**
+ * @deprecated Alias for {@link SquirclePluginOptions.amtVar}; see there.
+ */
"amt-var"?: string;
/** CSS custom property name for the intermediate corrected radius (default: "--squircle-r") */
+ /**
+ * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at
+ * build time. This option still works and still wins.
+ */
rVar?: string;
- /** @plugin CSS alias for rVar */
+ /**
+ * @deprecated Alias for {@link SquirclePluginOptions.rVar}; see there.
+ */
"r-var"?: string;
/** Class name prefix for utilities (default: "squircle") */
prefix?: string;
diff --git a/package/src/test-utils.ts b/package/src/test-utils.ts
index 6d081cd..79d45cd 100644
--- a/package/src/test-utils.ts
+++ b/package/src/test-utils.ts
@@ -65,10 +65,14 @@ export function createCompiler(srcDir: string) {
return extractUtilitiesLayer(compiler.build(candidates));
}
- async function compilePlugin(candidates: string[], pluginBlock = ""): Promise {
+ async function compilePlugin(
+ candidates: string[],
+ pluginBlock = "",
+ plugin = "./tailwind.ts",
+ ): Promise {
const pluginDecl = pluginBlock
- ? `@plugin "./tailwind.ts" {\n${pluginBlock}\n}`
- : `@plugin "./tailwind.ts";`;
+ ? `@plugin "${plugin}" {\n${pluginBlock}\n}`
+ : `@plugin "${plugin}";`;
const input = `
@import "tailwindcss";
${pluginDecl}
@@ -81,5 +85,22 @@ ${pluginDecl}
return extractUtilitiesLayer(compiler.build(candidates));
}
- return { compileCss, compilePlugin };
+ /** The whole build, not just the utilities layer — for base-layer output. */
+ async function compilePluginAll(
+ candidates: string[],
+ pluginBlock = "",
+ plugin = "./tailwind.ts",
+ ): Promise {
+ const pluginDecl = pluginBlock
+ ? `@plugin "${plugin}" {\n${pluginBlock}\n}`
+ : `@plugin "${plugin}";`;
+ const compiler = await compile(`\n@import "tailwindcss";\n${pluginDecl}\n`, {
+ base: srcDir,
+ loadStylesheet,
+ loadModule,
+ });
+ return compiler.build(candidates);
+ }
+
+ return { compileCss, compilePlugin, compilePluginAll };
}
diff --git a/package/src/variants.ts b/package/src/variants.ts
index 8a01759..bb470b5 100644
--- a/package/src/variants.ts
+++ b/package/src/variants.ts
@@ -4,8 +4,62 @@
*/
export const DEFAULT_AMT = 2 as const;
-export const DEFAULT_AMOUNT_VAR_NAME = "--squircle-amt" as const;
-export const DEFAULT_R_VAR_NAME = "--squircle-r" as const;
+
+/* ── Pill custom properties ───────────────────────────────────
+ * Namespaced, because `--pill-*` is the kind of name a design
+ * system is likely to have taken already.
+ *
+ * The worklet names these in `inputProperties`, a static list read
+ * once at registration, so there is no hook to rename them per
+ * project the way `--squircle-amt` can be. The prefix is therefore
+ * fixed when the package is built: set SQUIRCLE_CSS_NAMESPACE to
+ * change it, and the worklet, the plugins and the stylesheet all
+ * follow from the same value.
+ *
+ * Both the Tailwind plugin and squircle-pill.css register these
+ * with initial values matching the worklet's own fallbacks.
+ * ──────────────────────────────────────────────────────────── */
+declare const __SQUIRCLE_CSS_NAMESPACE__: string | undefined;
+
+/**
+ * Vite inlines the define; build scripts that import this module under plain
+ * `tsx` get no define, so they read the environment directly. `typeof` on an
+ * undeclared name is safe, which is what makes the first branch usable either
+ * way.
+ */
+export const CSS_NAMESPACE: string =
+ typeof __SQUIRCLE_CSS_NAMESPACE__ === "string"
+ ? __SQUIRCLE_CSS_NAMESPACE__
+ : (globalThis.process?.env?.SQUIRCLE_CSS_NAMESPACE ?? "squircle");
+
+/** `---`, e.g. `--squircle-amt`. */
+const coreVar = (name: string) => `--${CSS_NAMESPACE}-${name}`;
+/** `---pill-`, e.g. `--squircle-pill-border-width`. */
+const pillVar = (name: string) => `--${CSS_NAMESPACE}-pill-${name}`;
+
+export const PILL_AMT_VAR_NAME: string = pillVar("amt");
+export const PILL_EASE_SPREAD_VAR_NAME: string = pillVar("ease-spread");
+export const DEFAULT_PILL_AMT = 2 as const;
+/** Border the worklet draws itself, since a CSS border cannot follow the shape. */
+export const PILL_BORDER_WIDTH_VAR_NAME: string = pillVar("border-width");
+export const PILL_BORDER_COLOR_VAR_NAME: string = pillVar("border-color");
+export const PILL_BORDER_STYLE_VAR_NAME: string = pillVar("border-style");
+export const PILL_BORDER_STYLE_FALLBACK = "solid" as const;
+/** Internal: what the worklet keys stroke mode off, set on the ring only. */
+export const PILL_STROKE_WIDTH_VAR_NAME: string = pillVar("stroke-width");
+export const DEFAULT_PILL_EASE_SPREAD = 1 as const;
+/**
+ * The shared amount and radius properties, from the same namespace as
+ * everything else. At the default namespace these are the documented
+ * `--squircle-amt` and `--squircle-r`, unchanged.
+ *
+ * The `amtVar` and `rVar` plugin options still override them and take
+ * precedence, but are deprecated: they predate the namespace and only ever
+ * reached utilities the plugins emit, never the paint worklet, which names the
+ * properties it reads in a static `inputProperties` list.
+ */
+export const DEFAULT_AMOUNT_VAR_NAME: string = coreVar("amt");
+export const DEFAULT_R_VAR_NAME: string = coreVar("r");
/** Static value for `squircle-full`; matches Tailwind's `rounded-full`. */
export const FULL_RADIUS = "calc(infinity * 1px)" as const;
/** Static value for `squircle-none`; matches Tailwind's `rounded-none`. */
diff --git a/package/vite.config.ts b/package/vite.config.ts
index ec5db17..b6fe7e5 100644
--- a/package/vite.config.ts
+++ b/package/vite.config.ts
@@ -1,16 +1,78 @@
+import { readFileSync } from "node:fs";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus";
+/**
+ * Prefix for every custom property this package owns, inlined at build time
+ * from `squircle.cssNamespace` in package.json.
+ *
+ * The paint worklet names the properties it reads in `inputProperties`, which
+ * is a static list read once when the worklet registers — there is no per-
+ * element or per-consumer hook to rename them through. So the namespace has to
+ * be fixed when the code is built, not when it is used, and it is shared from
+ * here with the worklet, the plugins and the stylesheet so they cannot disagree.
+ *
+ * SQUIRCLE_CSS_NAMESPACE overrides it for a one-off build; `squircle.cssNamespace`
+ * in package.json is the committed default for a fork that wants it permanently.
+ *
+ * Every task that bakes the value in declares the variable in its `env`, which
+ * both forwards it to the task process and folds it into the cache key. An
+ * undeclared variable is stripped, so without that it would appear to work when
+ * a script was run directly and quietly do nothing through `vp run`.
+ */
+const CSS_NAMESPACE: string =
+ process.env.SQUIRCLE_CSS_NAMESPACE ||
+ JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).squircle
+ ?.cssNamespace ||
+ "squircle";
+
+/** Tasks whose output depends on the namespace. */
+const NAMESPACE_ENV = ["SQUIRCLE_CSS_NAMESPACE"];
+
+/**
+ * A registered paint worklet cannot be replaced or unregistered, so the worklet
+ * cannot be hot-swapped in place. Editing it therefore triggers a full page
+ * reload, which re-runs CSS.paintWorklet.addModule() against the fresh source.
+ */
+/** Replaces %SQUIRCLE_NS% in the dev page, so it never hardcodes the namespace. */
+const pillDevNamespace = () => ({
+ name: "pill-dev-namespace",
+ transformIndexHtml(html: string) {
+ return html.replaceAll("%SQUIRCLE_NS%", CSS_NAMESPACE);
+ },
+});
+
+const pillWorkletHmr = () => ({
+ name: "pill-worklet-hmr",
+ handleHotUpdate({ file, server }: { file: string; server: { ws: { send(p: unknown): void } } }) {
+ if (file.replace(/\\/g, "/").endsWith("src/pill-shape.worklet.ts")) {
+ server.ws.send({ type: "full-reload", path: "*" });
+ return [];
+ }
+ },
+});
+
export default defineConfig({
- plugins: [tailwindcss()],
+ plugins: [tailwindcss(), pillWorkletHmr(), pillDevNamespace()],
+ // Covers the dev server and the test run; `pack.define` covers the library
+ // build, which does not inherit this one.
+ define: {
+ __SQUIRCLE_CSS_NAMESPACE__: JSON.stringify(CSS_NAMESPACE),
+ },
test: {
include: ["src/**/*.test.ts"],
},
pack: {
+ define: {
+ __SQUIRCLE_CSS_NAMESPACE__: JSON.stringify(CSS_NAMESPACE),
+ },
entry: {
"tailwind/index": "./src/tailwind.ts",
+ "tailwind-pill/index": "./src/tailwind-pill.ts",
+ "tailwind-pill-border/index": "./src/tailwind-pill-border.ts",
"panda/index": "./src/panda.ts",
"stylex/index": "./src/stylex.ts",
+ "pill-shape.worklet": "./src/pill-shape.worklet.ts",
},
format: "esm",
dts: true,
@@ -18,16 +80,19 @@ export default defineConfig({
run: {
tasks: {
"test:tailwind": {
+ env: NAMESPACE_ENV,
// Matches tailwind.test.ts and tailwind-merge.test.ts; the latter
// compiles the generated utils.css, so the build has to run first.
command: "vp test run tailwind",
dependsOn: ["build"],
},
"test:css": {
+ env: NAMESPACE_ENV,
command: "vp test run squircle-css",
dependsOn: ["build"],
},
"test:radius": {
+ env: NAMESPACE_ENV,
command: "vp test run squircle-radius",
dependsOn: ["build"],
},
@@ -37,16 +102,39 @@ export default defineConfig({
"test:stylex": {
command: "vp test run stylex",
},
+ "test:pill": {
+ env: NAMESPACE_ENV,
+ command: "vp test run pill-shape",
+ },
test: {
command: "echo 'All tests passed'",
- dependsOn: ["test:tailwind", "test:css", "test:radius", "test:panda", "test:stylex"],
+ dependsOn: [
+ "test:tailwind",
+ "test:css",
+ "test:radius",
+ "test:panda",
+ "test:stylex",
+ "test:pill",
+ ],
},
"generate:stylex": {
command: "tsx scripts/generate-stylex.ts",
},
build: {
+ env: NAMESPACE_ENV,
command:
- "tsx scripts/generate-stylex.ts && vp pack && tsx scripts/generate-squircle-css.ts",
+ "tsx scripts/generate-stylex.ts && vp pack && tsx scripts/generate-squircle-css.ts && tsx scripts/copy-pill-assets.ts",
+ },
+ "build:pill": {
+ env: NAMESPACE_ENV,
+ command: "vp pack",
+ },
+ "pill-dev": {
+ env: NAMESPACE_ENV,
+ // The dev page loads the worklet straight from src/, which Vite
+ // compiles on the fly, so no build step is needed (and a stale dist/
+ // can no longer mask source edits).
+ command: "vp dev",
},
},
},
diff --git a/pill-test.html b/pill-test.html
new file mode 100644
index 0000000..0e4d508
--- /dev/null
+++ b/pill-test.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+ Pill Shape Test
+
+
+
+
+
+
Pill Shape Test
+
Testing squircle-pill utilities with Houdini paint worklet
+
+
+
Perfect Pills
+
+
+
Badge
+
+ ✓
+
+
+
+
+
+
Different Aspect Ratios
+
+
Wide
+
Tall
+
+ Square
+
+
+
+
+
+
Use Cases
+
+
+
Buttons
+
+
+
+
+
+
+
+
+
Tags & Badges
+
+ Tag
+ Status
+ Warning
+
+
+
+
+
Icon Buttons
+
+
+
+
+
+
+
+
+
+
+
+ Note: Open DevTools to check if paint(pill-shape) is being used or if
+ fallback to border-radius is active.
+
+ Explore Panda CSS utilities for squircles and pills with visual examples.
+
+
+
Squircle Corners
+
Squircle utilities consumed via the Panda preset's squircle* shorthands. Panda is configured with
+
Pill Shapes
+
+ Pill-shaped rectangles with G2-continuous transitions using Houdini CSS Paint API.
+
+
+
Edit on StackBlitz
StyleX
+ Explore StyleX utilities for squircles and pills with visual examples.
+
+
+
Squircle Corners
+
Squircle utilities authored as StyleX dynamic styles. Each variant is a
function that takes a radius (and an optional
superellipse amt) and produces a
@@ -19,6 +24,12 @@ import StyleXDemo from "../../components/StyleXDemo";
+
Pill Shapes
+
+ Pill-shaped rectangles with G2-continuous transitions using Houdini CSS Paint API.
+
+
+
Edit on StackBlitz
Tailwind
- This demo compares regular rounded-* corners
- with squircle squircle-* corners at several
- radii.
+ Explore Tailwind utilities for squircles and pills with visual examples.
+
+
+
Squircle Corners
+
+ Compare regular rounded-* corners
+ with squircle squircle-* corners at several radii.
+
Pill Shapes
+
+ Pill-shaped rectangles with G2-continuous transitions using Houdini CSS Paint API.
+