diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17fd523..409fac6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,5 +32,18 @@ jobs:
- run: npm ci
- # Typecheck, build, tests, and a dry-run pack of the published files.
+ # Type checking, build, tests, and a dry-run pack of the published files.
- run: npm run check
+
+ browser:
+ name: Browser tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:browser
diff --git a/.gitignore b/.gitignore
index 0ce2600..1b66e14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
node_modules/
dist/
examples/react/main.js
+examples/svelte/bundle.js
.artifacts/
.npm-cache/
*.tgz
*.tsbuildinfo
.DS_Store
+test-results/
+playwright-report/
diff --git a/README.md b/README.md
index e2f56d8..f484ff1 100644
--- a/README.md
+++ b/README.md
@@ -1,57 +1,18 @@
# squircles
-Small Figma-style squircles for TypeScript and React.
+[](https://www.npmjs.com/package/squircles)
+[](https://github.com/eiserv/squircles/actions/workflows/ci.yml)
+[](https://www.npmjs.com/package/squircles)
+[](https://github.com/eiserv/squircles/blob/main/src/index.ts)
+[](LICENSE)
-Adopting it changes an element's corner shape and nothing else. Background,
-border, shadow, hover, dark mode, and everything else stay in your CSS, where
-you already know how to write them.
+Figma-style corner smoothing for the web. Backgrounds, borders, shadows, and hover
+states stay in your CSS. No extra wrapper, optional React and Svelte integrations,
+and no runtime dependencies in the geometry or DOM packages.
-- zero dependencies in the geometry package;
-- React is an optional peer dependency;
-- works on any element — buttons, links, cards, images, inputs;
-- geometry and paint are read from ordinary CSS, not from props;
-- no data-URL masks, canvas, paint worklets, or CSS-in-JS;
-- responsive via `ResizeObserver`;
-- plain `border-radius` during SSR and before hydration;
-- ESM, strict TypeScript, and tree-shakeable entry points.
-
-## What it looks like
-
-`examples/playground.html` draws the same button twice — once through
-`createSquirclePath()`, once with plain `border-radius` — and updates both while
-you move radius, smoothing, size, and stroke width:
-
-```sh
-npm run playground
-```
-
-
-
-At button-sized radii the two shapes are close, which is why the overlay and the
-magnified corner are part of the page: they are the honest way to see the
-difference. Note the corner budget in the readout — at radius 24 on a 72px-tall
-button, Figma's constraint already reduces the effective smoothing.
-
-The difference shrinks with the radius. At a pill radius with `preserveSmoothing`,
-the smoothed ends are almost identical to the semicircular `border-radius`
-ends. But theres a sweet-spot- where differences are visible really well:
-
-
-
-## Why this exists
-
-Figma corner smoothing is not the same shape as `border-radius`. Existing web
-solutions generally create an SVG path or clip path. A clip path works well for
-fills, but a button border is easy to get wrong: subtracting a pixel from the
-radius does not create the exact inner offset of a smoothed curve, and combining
-a clip path with a separately rasterized mask can produce uneven-looking
-one-pixel edges.
-
-This package draws the border as a native SVG stroke on the smoothed path
-itself, inset by half its width. The stroke is therefore exactly as thick at a
-smoothed corner as it is along a straight edge, and nothing is clipped away.
+
+
+
## Install
@@ -59,198 +20,101 @@ smoothed corner as it is along a straight edge, and nothing is clipped away.
npm install squircles
```
-## React
-
-Give the element a class and let CSS do the rest:
-
-```tsx
-import { Squircle } from "squircles/react";
-
-export function CallToAction() {
- return (
-
- Learn more
-
- );
-}
-```
+## One stylesheet, several ways to use it
```css
.button {
- background: linear-gradient(#243a8f, #192d73);
- border: 1px solid #e5e7eb;
+ background: #242424;
+ color: white;
+ border: 1px solid #666;
border-radius: 12px;
--squircle-smoothing: 1;
- min-height: 48px;
- padding: 12px 16px;
- color: white;
- transition: background-color 150ms ease;
-}
-
-.button:hover {
- background: #14245e;
-}
-
-.button:focus-visible {
- outline: 2px solid #192d73;
- outline-offset: 4px;
+ padding: 12px 20px;
}
```
-The component reads `border-radius` and `--squircle-smoothing` from your CSS, so
-it needs no props at all beyond `as`. Pass `radius`, `smoothing`, or
-`preserveSmoothing` only when you want to override the stylesheet.
-
-Cards and images work the same way:
+**React** (18 or later):
```tsx
-
-
-
Some text
-
-```
+import { Squircle } from "squircles/react";
-```css
-.card {
- border-radius: 20px;
- --squircle-smoothing: 1;
- background: white;
- border: 1px solid #dce1ed;
- box-shadow: 0 12px 32px rgba(25, 45, 115, 0.18);
-}
+
+ Learn more
+
```
-The shadow follows the smoothed contour rather than the `border-radius` one.
+**Svelte** as an [action](https://svelte.dev/docs/svelte/use):
-Use `as="a"` for a native link, or pass a ref-forwarding component such as
-Next.js `Link`. In the Next.js App Router the file needs `"use client"`.
+```svelte
+
-Do not enable `clipContent` on controls if their focus outline must remain
-outside the shape. It is intended for cards whose content bleeds to the edge.
+
+```
-## Without React
+**JavaScript, Vue, or other frameworks** through the DOM entry point:
```ts
import { applySquircle } from "squircles/dom";
-const dispose = applySquircle(document.querySelector(".card"));
+const element = document.querySelector(".button");
+const cleanup = element ? applySquircle(element) : () => {};
+// Call when the element is removed.
+cleanup();
```
-With no options it reads everything from CSS, exactly like the React component,
-and returns a cleanup function. It works on any element, in any framework, and
-in plain HTML.
-
-## Existing components
-
-`SquircleSurface` adds only the visual SVG layer, with fill and stroke on a
-single path. Use it inside a component that already owns positioning and
-semantics, or when you need a semi-transparent border:
-
-```tsx
-import { SquircleSurface } from "squircles/react";
-
-function ExistingButton() {
- return (
-
- );
-}
-```
-
-The parent must be positioned, its rectangular background/border must be
-transparent, and its content must paint above the SVG.
+The integrations read `border-radius` and `--squircle-smoothing` from CSS.
+Class, style, and size changes, along with hover and focus, update the shape.
+Server-rendered elements initially use their ordinary CSS radius.
## Geometry only
-The root entry point does not import React or touch the DOM:
-
```ts
import { createSquirclePath, createSquircleClipPath } from "squircles";
-const path = createSquirclePath({
- width: 120,
- height: 48,
- radius: 8,
- smoothing: 1,
- inset: 0.5,
-});
-
-const clipPath = createSquircleClipPath({ width: 120, height: 48, radius: 8 });
+const options = { width: 200, height: 96, radius: 24, smoothing: 1 };
+const path = createSquirclePath(options);
+const clipPath = createSquircleClipPath(options);
```
-`radius` accepts a number or individual corners:
-
-```ts
-radius={{
- topLeft: 24,
- topRight: 12,
- bottomRight: 24,
- bottomLeft: 12,
-}}
-```
+`radius` supports individual corners; `smoothing` ranges from `0` to `1`.
+When space is limited, smoothing is constrained as in Figma.
+`preserveSmoothing: true` keeps it by compressing the control points.
+`inset` moves the path inward, for example by half an SVG stroke width.
-## What is not supported
+
+
+
-These are deliberate limits, not bugs:
+## Limits and more examples
-- **Focus rings.** `outline` always follows `border-radius`. With
- `outline-offset` the mismatch is hard to see; a ring sitting directly on the
- edge will not match.
-- **Different borders per side.** Only `border-top-width` and
- `border-top-color` are read.
-- **Dashed and dotted borders.** Only `border-style: solid` is drawn.
-- **`inset` shadows.** They are left on the element and keep following
- `border-radius`.
-- **Semi-transparent borders.** The surface's clip edge shows through the
- stroke. Use `SquircleSurface` for that case.
-- **`backdrop-filter`.** The element itself is not clipped, so a backdrop
- filter stays rectangular.
-- **No JavaScript, or before hydration.** The element renders with its own
- `border-radius`. There is no layout shift, but there is no squircle either.
+- Borders must be solid and uniform; different borders per side are not supported.
+- Focus outlines and inset shadows still follow `border-radius`.
+- Images and inputs receive a clip path that also clips their border and shadow.
+ Use a wrapper when needed.
+- For semi-transparent borders, use `SquircleSurface` from `squircles/react`.
+- Without `ResizeObserver`, measurement runs on startup and window resize.
+ Other layout changes require a polyfill.
-## Migrating from 0.1
-
-`Squircle` no longer takes paint props. Move them into your stylesheet:
-
-| 0.1 prop | Now |
-|---|---|
-| `fill="#192d73"` | `background: #192d73;` |
-| `stroke="#e5e7eb"` | `border-color: #e5e7eb;` |
-| `strokeWidth={1}` | `border-width: 1px; border-style: solid;` |
-| `radius={12}` | `border-radius: 12px;` (or keep the prop) |
-| `smoothing={1}` | `--squircle-smoothing: 1;` (or keep the prop) |
-| `surfaceClassName` | style `[data-squircle-fill]` / `[data-squircle-stroke]` |
-| `surfaceStyle` | style `[data-squircle-fill]` / `[data-squircle-stroke]` |
-
-`SquircleSurface` is unchanged and still accepts all of them.
+[Integration, options, and migration](docs/integration.md) ·
+[Architecture and limits](docs/architecture.md)
## Development
```sh
-npm install
+npm ci
npm run check
+npx playwright install chromium
+npm run test:browser
+npm run playground
```
-`npm run playground` builds the package and serves two example pages:
-[examples/playground.html](examples/playground.html) on
-, which shapes its own interface with the library, and
-a React version on . `npm run visual:fixture`
-writes a static rasterization fixture to `.artifacts/`.
-
-See [docs/architecture.md](docs/architecture.md) for the rendering rationale
-and constraints.
-
-### Releasing
+Playground: [localhost:5173](http://localhost:5173/), React:
+[localhost:5173/react](http://localhost:5173/react/), Svelte:
+[localhost:5173/svelte](http://localhost:5173/svelte/).
+`npm run screenshots` regenerates the README images with transparent shadows.
+`npm run visual:fixture` creates more geometry examples in `.artifacts/`.
Commits on `main` follow [Conventional Commits](https://www.conventionalcommits.org/).
-release-please keeps a release PR open that bumps the version and updates
-[CHANGELOG.md](CHANGELOG.md); merging it tags the release, creates the GitHub
-Release, and publishes to npm.
+release-please opens a release PR; merging it publishes the npm package.
diff --git a/browser-tests/playground.spec.mjs b/browser-tests/playground.spec.mjs
new file mode 100644
index 0000000..9a308fd
--- /dev/null
+++ b/browser-tests/playground.spec.mjs
@@ -0,0 +1,208 @@
+import { test, expect } from "@playwright/test";
+
+test("playground fits small screens and updates the outline", async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.goto("/");
+ const path = page.locator("#squircle path");
+ await expect(path).toHaveAttribute("d", /^M /);
+ const before = await path.getAttribute("d");
+ await page.locator("#radius").fill("0");
+ await expect(path).not.toHaveAttribute("d", before);
+ await expect(page.locator("#note")).toHaveText("Without a radius, both shapes are identical.");
+ await page.locator("#width").fill("420");
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ expect(errors).toEqual([]);
+});
+
+test("DOM measurement handles vertical and sideways writing modes", async ({ page }) => {
+ await page.goto("/");
+ for (const writingMode of ["horizontal-tb", "vertical-rl", "sideways-rl"]) {
+ await page.evaluate(async (mode) => {
+ document.getElementById("measurement")?.remove();
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "measurement";
+ element.style.cssText = `width:160px;height:80px;writing-mode:${mode};border:1px solid black;border-radius:16px;background:white;box-sizing:border-box`;
+ document.body.append(element);
+ applySquircle(element);
+ }, writingMode);
+ await expect(page.locator("#measurement [data-squircle-stroke]")).toHaveAttribute("viewBox", "0 0 160 80");
+ }
+});
+
+test("inline changes survive redraw and cleanup", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("button");
+ element.id = "dynamic";
+ element.style.cssText = "width:180px;height:64px;border-radius:16px;background:rgb(10, 10, 10);padding:8px";
+ document.body.append(element);
+ window.cleanup = applySquircle(element);
+ });
+ const element = page.locator("#dynamic");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(10, 10, 10)");
+ await element.evaluate((node) => {
+ node.style.backgroundColor = "rgb(70, 70, 70)";
+ node.style.padding = "12px";
+ node.style.setProperty("--squircle-smoothing", "0.5");
+ });
+ await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(70, 70, 70)");
+ await page.evaluate(() => { window.cleanup(); window.cleanup(); });
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(0);
+ await expect(element).toHaveCSS("background-color", "rgb(70, 70, 70)");
+ await expect(element).toHaveCSS("padding-top", "12px");
+ await expect(element).toHaveCSS("--squircle-smoothing", "0.5");
+});
+
+test("startup, window resize, and cleanup work without ResizeObserver", async ({ page }) => {
+ await page.addInitScript(() => { window.ResizeObserver = undefined; });
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "fallback";
+ element.style.cssText = "width:160px;height:80px;border:1px solid black;border-radius:16px;box-sizing:border-box";
+ document.body.append(element);
+ window.cleanup = applySquircle(element);
+ });
+ await expect(page.locator("#fallback svg")).toHaveAttribute("viewBox", "0 0 160 80");
+ await page.evaluate(() => {
+ document.getElementById("fallback").style.width = "200px";
+ window.dispatchEvent(new Event("resize"));
+ });
+ await expect(page.locator("#fallback svg")).toHaveAttribute("viewBox", "0 0 200 80");
+ await page.evaluate(() => window.cleanup());
+ await expect(page.locator("#fallback svg")).toHaveCount(0);
+});
+
+test("React follows a change of the native host element", async ({ page }) => {
+ await page.goto("/react/");
+ const element = page.locator("[data-switch]");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+ await page.getByRole("button", { name: "Switch element" }).click();
+ await expect(element).toHaveJSProperty("tagName", "A");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+ await page.getByRole("button", { name: "Switch element" }).click();
+ await expect(element).toHaveJSProperty("tagName", "BUTTON");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+});
+
+test("Svelte action updates options and safely handles repeated cleanup", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { squircle } = await import("/dist/svelte/index.js");
+ const element = document.createElement("button");
+ element.id = "action";
+ element.style.cssText = "width:160px;height:80px;border:1px solid black;border-radius:16px;box-sizing:border-box";
+ document.body.append(element);
+ window.action = squircle(element);
+ });
+ const stroke = page.locator("#action path");
+ await expect(stroke).toHaveAttribute("d", /^M /);
+ const before = await stroke.getAttribute("d");
+ await page.evaluate(() => window.action.update({ radius: 0 }));
+ await expect(stroke).not.toHaveAttribute("d", before);
+ await page.evaluate(() => window.action.update());
+ await expect(stroke).toHaveAttribute("d", before);
+ await page.evaluate(() => { window.action.destroy(); window.action.destroy(); window.action.update({ radius: 24 }); });
+ await expect(page.locator("#action [data-squircle-fill]")).toHaveCount(0);
+ await expect(page.locator("#action")).toHaveCSS("border-top-color", "rgb(0, 0, 0)");
+});
+
+test("idle rendering produces no further DOM mutations", async ({ page }) => {
+ await page.goto("/react/");
+ await expect(page.locator("[data-switch] [data-squircle-fill]")).toHaveCount(1);
+ const count = await page.evaluate(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ let count = 0;
+ const observer = new MutationObserver((records) => { count += records.length; });
+ observer.observe(document.querySelector("[data-switch]"), { attributes: true, childList: true, subtree: true });
+ for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame);
+ observer.disconnect();
+ return count;
+ });
+ expect(count).toBe(0);
+});
+
+test("Svelte renders actions and attachments with reactive changes", async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto("/svelte/");
+ const stroke = page.locator("[data-action] path");
+ await expect(stroke).toHaveAttribute("d", /^M /);
+ await expect(page.locator("[data-attachment] [data-squircle-fill]")).toHaveCount(1);
+ const before = await stroke.getAttribute("d");
+ await page.getByRole("slider", { name: "Smoothing" }).fill("0");
+ await expect(stroke).not.toHaveAttribute("d", before);
+ await page.getByRole("button", { name: "Remove example" }).click();
+ await expect(page.locator("[data-action]")).toHaveCount(0);
+ await page.getByRole("button", { name: "Show example" }).click();
+ await expect(stroke).toHaveAttribute("d", /^M /);
+ expect(errors).toEqual([]);
+});
+
+test("removed inline styles and priorities remain correct after cleanup", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "inline";
+ element.style.cssText = "width:160px;height:80px;background:rgb(20,20,20);padding:4px;border-radius:16px";
+ document.body.append(element);
+ window.cleanup = applySquircle(element);
+ });
+ await expect(page.locator("#inline [data-squircle-fill]")).toHaveCount(1);
+ await page.locator("#inline").evaluate((node) => {
+ node.style.removeProperty("padding");
+ node.style.setProperty("background-color", "rgb(80, 80, 80)", "important");
+ });
+ await expect(page.locator("#inline [data-squircle-fill]")).toHaveCSS("background-color", "rgb(80, 80, 80)");
+ await page.evaluate(() => window.cleanup());
+ const styles = await page.locator("#inline").evaluate((node) => ({
+ padding: node.style.padding,
+ priority: node.style.getPropertyPriority("background-color"),
+ color: node.style.backgroundColor,
+ }));
+ expect(styles).toEqual({ padding: "", priority: "important", color: "rgb(80, 80, 80)" });
+});
+
+test("fully contracted shadows do not prevent rendering", async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "shadow";
+ element.style.cssText = "width:160px;height:80px;background:white;border-radius:16px;box-shadow:0 2px 4px -100px oklch(40% 0 0)";
+ document.body.append(element);
+ applySquircle(element);
+ });
+ await expect(page.locator("#shadow [data-squircle-fill]")).toHaveCount(1);
+ await expect(page.locator("#shadow [data-squircle-shadow]")).toHaveCount(0);
+ expect(errors).toEqual([]);
+});
+
+test("a fully replaced style attribute survives cleanup", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "replaced-styles";
+ element.style.cssText = "width:160px;height:80px;background:black;border:1px solid black;border-radius:16px;padding:4px";
+ document.body.append(element);
+ window.cleanup = applySquircle(element);
+ });
+ const element = page.locator("#replaced-styles");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+ await element.evaluate((node) => { node.style.cssText = "width:200px;height:100px;background:rgb(60,60,60);border:2px solid rgb(90,90,90);border-radius:24px"; });
+ await expect(element.locator("svg")).toHaveAttribute("viewBox", "0 0 200 100");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(60, 60, 60)");
+ await page.evaluate(() => window.cleanup());
+ await expect(element).toHaveCSS("border-top-color", "rgb(90, 90, 90)");
+ await expect(element).toHaveCSS("padding-top", "0px");
+ await expect(element).toHaveCSS("background-color", "rgb(60, 60, 60)");
+});
diff --git a/docs/architecture.md b/docs/architecture.md
index b9d2a43..6af4bc4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,140 +1,95 @@
# Architecture
-## Why SVG instead of a mask
-
-The outer fill and border must be rasterized from the same geometry. Building a
-border from an outer clip path plus a separately calculated inner mask has
-three failure modes:
-
-1. reducing the radius does not create an exact parallel offset of a smoothed
- Bézier curve;
-2. `clip-path` and an encoded SVG mask can be rasterized in separate passes;
-3. fractional layout sizes can quantize those two passes differently.
-
-This package draws one SVG path with both `fill` and `stroke`. The path is inset
-by half the stroke width, so the complete stroke stays inside the component
-box. There is no inner contour to drift away from the outer contour.
-
-## Layers
-
-- `squircles` is pure geometry and has no runtime dependencies.
-- `squircles/dom` adds `applySquircle`, which shapes any element from its own
- CSS. It has no React dependency.
-- `squircles/react` adds the `Squircle`, `SquircleSurface`, `useSquirclePath`,
- and `useSquircleClipPath` APIs.
-- React is an optional peer dependency. The geometry entry point does not load
- React and does not touch the DOM.
-- `src/css/` converts `CSSStyleDeclaration` values into plain data. It never
- reaches for a DOM global, which is what makes it unit-testable under
- `node --test`.
-
-## Paint model
-
-`Squircle` renders only the host element and calls `applySquircle` in an effect,
-so the paint layers never pass through React reconciliation. That is also why a
-void element such as `img` works: nothing is rendered as its child.
-
-`applySquircle` appends up to three kinds of absolutely positioned layer inside
-the host, each with `pointer-events: none`:
-
-| Layer | z-index | Purpose |
-|---|---|---|
-| `[data-squircle-shadow]` (one per shadow) | -3 | An offset, blurred, colour-filled shape, clipped to the contour grown by the shadow's spread |
-| `[data-squircle-fill]` | -2 | The host's copied background longhands, clipped to the contour |
-| `[data-squircle-stroke]` | -1 | An SVG path carrying the border as a stroke |
-
-All three layers are offset outwards by the border width. The geometry is
-measured on the border box, but absolute offsets inside the host resolve against
-its padding box, so on a bordered element the two would disagree by the border
-width on every side — the outermost band of the contour would fall outside the
-layer and the corners would be cut off square.
-
-The host itself is deliberately **not** clipped. `clip-path` removes everything
-outside the contour, including shadows and including descendants, so clipping
-the host would make shadows impossible. Instead the host's background is copied
-onto the fill layer and the host is set to `transparent`; its `border-color` is
-set to `transparent` rather than removing the border, so the border box and the
-layout stay intact.
-
-Separating surface and stroke does not reintroduce the two-contour problem. The
-stroke is inset by half its width and completely covers the fill's clip edge, so
-there is still never a fill edge and a border edge that must agree. A
-semi-transparent border is the exception: the clip edge shows through it, and
-`SquircleSurface` remains the escape hatch for that case.
-
-## Reading the author's CSS
-
-Because the layers overwrite `background-color`, `background-image`,
-`border-color`, and `box-shadow` on the host, those overrides are lifted again
-for the duration of every read. Two consequences are worth knowing:
-
-- each re-read costs one style recalculation, which is why reads are coalesced
- into a single `requestAnimationFrame`;
-- transitions are suppressed during the read. Lifting an override on a property
- that carries a transition makes the browser interpolate from the override, so
- a plain read returns the value in flight rather than the authored target. A
- hovered button with a background transition would otherwise keep its idle
- colour.
-
-## Geometry
-
-The curve uses the cubic Bézier construction described in Figma's
-"Desperately seeking squircles" article:
-
-- smoothing `0` is a normal circular corner;
-- smoothing `1` uses the full transition and no circular arc;
-- each corner gets a proportional budget on its two adjacent sides;
-- by default smoothing is reduced if the requested curve would exceed that
- budget, matching Figma's documented constraint;
-- `preserveSmoothing` keeps the requested smoothing and compresses the first
- Bézier controls instead.
-
-The implementation is original TypeScript structured around immutable corner
-data, explicit input validation, per-corner radii, and stroke insetting. The
-published Figma article and the MIT-licensed reference implementations are
-credited in `NOTICE`.
-
-## Rendering contract
-
-`Squircle` is the high-level, polymorphic component. It:
-
-- preserves the semantic host (`div`, `button`, `a`, `img`, or a custom React
- component);
-- measures only layout-size changes with `ResizeObserver`;
-- falls back to the element's own `border-radius` before the first measurement,
- which needs no special handling because the host keeps its own CSS;
-- does not clip focus outlines by default;
-- re-reads on `ResizeObserver`, on `class` and `style` mutations, and on
- `pointerenter`, `pointerleave`, `focusin`, `focusout`, `transitionrun`, and
- `transitionend`.
-
-Void elements (`img`, `input`, and the rest) cannot hold layers. They get
-`clip-path` directly and therefore support neither a border nor a shadow; wrap
-them in a `Squircle` when those are needed.
-
-`SquircleSurface` is the low-level SVG visual. It is useful inside an existing
-component that already owns positioning, semantics, and fallback behavior.
-
-## Known boundaries
-
-No renderer can make the two physical edges of an element land on identical
-device-pixel phases when the element itself has a fractional physical-pixel
-width. This package avoids amplifying that unavoidable rasterization detail:
-it does not quantize layout to arbitrary quarter pixels, and it never combines
-two independently rasterized contours.
-
-Beyond that, and by decision rather than by accident:
-
-- `outline`, and therefore every focus ring, follows `border-radius`. No
- renderer exposes a hook to change that.
-- Only `border-top-width`, `border-top-style`, and `border-top-color` are read.
- Per-side borders cannot be expressed as one stroke.
-- Only `border-style: solid` is drawn. `stroke-dasharray` could approximate
- dashed and dotted borders, but the dash rhythm around a smoothed curve would
- not match what CSS draws.
-- `inset` shadows are ignored and left on the element.
-- Multiple shadows become one layer each. They composite as stacked layers
- rather than the way `box-shadow` composites its list.
-- `backdrop-filter` on the host stays rectangular, because the host is not
- clipped.
-- An element whose size is animated recalculates its path once per frame.
+## Package boundaries
+
+- `squircles`: pure geometry with no DOM access or runtime dependencies.
+- `squircles/dom`: CSS-driven rendering through `applySquircle`.
+- `squircles/react`: components and hooks, with React as an optional peer.
+- `squircles/svelte`: an action with updates and cleanup, without a Svelte import.
+- `src/css/`: structural readers for computed CSS values, testable in Node.
+
+The public API, documentation, comments, examples, and error messages use English.
+
+## Geometry and borders
+
+The geometry follows Figma's published cubic Bézier construction:
+`smoothing: 0` uses circular arcs; `1` uses fully smoothed transitions.
+Each corner receives a proportional share of its adjacent sides. When space is
+limited, smoothing is constrained. `preserveSmoothing` instead compresses the
+first control points.
+
+Borders use an SVG stroke on a path inset by half the stroke width. There is no
+separate inner mask: simply shrinking a corner radius would not produce an exact
+parallel offset of a Bézier curve. Sources and acknowledgments are in `NOTICE`.
+
+## Rendering layers
+
+`Squircle` renders only its native element. An effect binds `applySquircle` to
+that element, keeping rendering layers outside React reconciliation. Changing
+`as` removes the previous binding and measures the new element.
+
+| Layer | Stacking order | Purpose |
+| --- | --- | --- |
+| `[data-squircle-shadow]` | -3 | An offset, blurred shape for each outer shadow |
+| `[data-squircle-fill]` | -2 | A copied background clipped to the smoothed shape |
+| `[data-squircle-stroke]` | -1 | An SVG stroke on the smoothed path |
+
+All layers ignore pointer events. Their negative offset by the border width
+compensates for the difference between padding and border boxes. The original
+border becomes transparent but retains its width to prevent layout shifts.
+The host is not clipped by default, keeping shadows and focus outlines visible.
+
+The SVG stroke covers the clipped background edge. Semi-transparent borders may
+expose that edge; `SquircleSurface` avoids this by drawing fill and stroke
+together on one SVG path.
+
+## Reading and restoring CSS
+
+Our overrides are temporarily lifted to read authored styles. Transitions stay
+disabled during the read to prevent new transitions or endless updates.
+Updates are coalesced per animation frame; our own mutation records are discarded
+afterward.
+
+Each element stores its authored and most recently applied inline styles.
+Differences from the applied state are merged into the authored state, preserving
+later framework changes across redraws and cleanup. Assigning exactly the same
+value already applied by the library cannot be distinguished from our override.
+
+Only one binding per element is supported. Cleanup is idempotent and removes
+observers, event handlers, pending frames, and our own layers.
+
+## Size measurement
+
+DOM and React share the same measurement code. `ResizeObserver` provides logical
+inline and block dimensions, which are mapped to physical width and height using
+`writing-mode`, including `vertical-*` and `sideways-*`. Sizes are rounded to
+1/64 pixel to avoid updates caused by tiny measurement differences.
+
+Without `ResizeObserver`, dimensions are measured on startup and window resize.
+Other layout changes require a polyfill. Fixed sizes bypass measurement in the
+React hooks. Boxes with zero width or height have no visible valid path.
+
+## Limits
+
+- `outline` and inset shadows still follow ordinary CSS rounding.
+- Only solid borders based on the top border values are drawn.
+- Elliptical radii are reduced to the inscribed circle.
+- Void HTML elements such as `img` and `input` receive a clip path directly,
+ which also clips their own border and shadow.
+- `backdrop-filter` remains rectangular unless `clipContent` is enabled.
+- Shadow lists are composited as individual layers. Inset shadows are not
+ redrawn; in mixed lists, they disappear when the original shadow is suppressed.
+- Changes exclusively to ancestors or stylesheets need an observed event on the
+ element, such as a size or class change.
+- Background transitions are forwarded; geometry and border changes are redrawn
+ at update time.
+- Fractional physical pixels can still produce slightly different rasterization
+ on opposite edges.
+
+## Verification and images
+
+`npm run check` verifies types, geometry, CSS readers, server-side React rendering,
+and package contents. `npm run test:browser` checks DOM lifecycles, style changes,
+writing modes, and the playground in Chromium. `npm run screenshots` captures the
+playground and renders its shadow into a transparent PNG margin. Image widths in
+the README are explicitly limited.
diff --git a/docs/integration.md b/docs/integration.md
new file mode 100644
index 0000000..9da475d
--- /dev/null
+++ b/docs/integration.md
@@ -0,0 +1,149 @@
+# Integration
+
+## Start with CSS
+
+The DOM, React, and Svelte integrations share the same renderer.
+`border-radius` and the following CSS custom properties define the shape:
+
+```css
+.card {
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 24px;
+ --squircle-smoothing: 1;
+ --squircle-preserve-smoothing: 1;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
+}
+```
+
+Percentage radii resolve per axis. Elliptical CSS corners use the inscribed
+circle. The top border determines the width and color of all sides. Outer
+shadows also support `oklch()` and `color()`. Inset and outer shadows cannot be
+combined: for mixed lists, the replacement rendering suppresses the original
+shadow, including its inset entries.
+
+The optional `radius`, `smoothing`, `preserveSmoothing`, and `clipContent` values
+override CSS. `clipContent` also clips children and external focus outlines;
+leave it disabled on controls that need those outlines.
+
+Changes to the element's own `class` and `style` attributes, size, hover and focus
+states, and the start and end of its own CSS transitions trigger updates.
+Changes exclusively to ancestors, stylesheets, or media queries are not detected
+automatically without one of these events.
+
+## React and Next.js
+
+```tsx
+import { Squircle } from "squircles/react";
+
+export function Card() {
+ return (
+
+
+
All native element properties remain available.
+
+ );
+}
+```
+
+`as` also accepts custom components that forward their ref to an HTML element,
+such as Next.js `Link`. In the App Router, the calling file needs `"use client"`.
+Geometry functions can also run on the server. `Squircle` initially renders only
+its native element there, with CSS supplying the rounded-corner fallback.
+
+`useSquirclePath` and `useSquircleClipPath` measure a custom ref. With the option
+`size: { width, height }`, they work without observation and during server-side
+rendering. Empty boxes produce an empty path or `none`.
+
+### Existing components and semi-transparent borders
+
+`SquircleSurface` draws fill and stroke together on one SVG path:
+
+```tsx
+import { SquircleSurface } from "squircles/react";
+
+
+```
+
+The parent needs `position: relative`, transparent backgrounds and borders,
+and text above the SVG, for example with `position: relative` on the `span`.
+`width` and `height` also allow fixed dimensions without measurement.
+
+## Svelte
+
+The action uses the [`update` and `destroy` lifecycle](https://svelte.dev/docs/svelte-action).
+It imports no Svelte runtime and works with Svelte 4 and 5.
+
+```svelte
+
+
+
+
+```
+
+New options replace previous ones. Removing the element cleans up observers and
+rendered layers. In Svelte 5.29 or later, the DOM function can also be used
+directly as an [attachment](https://svelte.dev/docs/svelte/@attach):
+
+```svelte
+
+
+
+```
+
+## Vue and other frameworks
+
+`applySquircle` needs a mounted HTML element and returns a cleanup function.
+For example, in Vue 3:
+
+```vue
+
+
+
+
+
+```
+
+CSS changes on the element are picked up automatically. To change geometry
+options, clean up the previous binding and apply it again. Cleanup can be called
+repeatedly. Later inline style changes survive redraws and cleanup.
+Only one active binding per element is supported.
+
+## Migrating from 0.1
+
+Since 0.2, `Squircle` takes its paint styles from CSS:
+
+| Previous prop | CSS or replacement |
+| --- | --- |
+| `fill` | `background` |
+| `stroke` | `border-color` |
+| `strokeWidth` | `border-width` and `border-style: solid` |
+| `radius` | `border-radius`, or keep using the option |
+| `smoothing` | `--squircle-smoothing`, or keep using the option |
+| `surfaceClassName`, `surfaceStyle` | `[data-squircle-fill]` and `[data-squircle-stroke]` |
+
+`SquircleSurface` still supports SVG paint props.
diff --git a/docs/media/playground.png b/docs/media/playground.png
index cb693f1..71e3957 100644
Binary files a/docs/media/playground.png and b/docs/media/playground.png differ
diff --git a/docs/media/smoothing-extremes.png b/docs/media/smoothing-extremes.png
index cf2f68b..61234f1 100644
Binary files a/docs/media/smoothing-extremes.png and b/docs/media/smoothing-extremes.png differ
diff --git a/docs/superpowers/plans/2026-07-28-css-driven-squircles.md b/docs/superpowers/plans/2026-07-28-css-driven-squircles.md
index 47985b6..8d8fd89 100644
--- a/docs/superpowers/plans/2026-07-28-css-driven-squircles.md
+++ b/docs/superpowers/plans/2026-07-28-css-driven-squircles.md
@@ -1695,8 +1695,8 @@ Insert after the `.insights` section, before ``:
- Card mit Schatten
- Hintergrund, Rand und Schatten kommen aus CSS.
+ Card with a shadow
+ Background, border, and shadow come from CSS.
@@ -1708,7 +1708,7 @@ Insert after the `.insights` section, before ``:
Badge
-
- The same button, drawn twice. On the left with Figma corner smoothing,
- on the right with plain border-radius. Move the sliders to
- see where the two shapes disagree.
-
+
+
squircles
+
Smoother corners, straight from your CSS. Compare smoothing with border-radius.