Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
268 changes: 66 additions & 202 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,256 +1,120 @@
# squircles

Small Figma-style squircles for TypeScript and React.
[![npm version](https://img.shields.io/npm/v/squircles?style=flat-square&color=242424)](https://www.npmjs.com/package/squircles)
[![CI](https://img.shields.io/github/actions/workflow/status/eiserv/squircles/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/eiserv/squircles/actions/workflows/ci.yml)
[![npm downloads](https://img.shields.io/npm/dm/squircles?style=flat-square&color=666666)](https://www.npmjs.com/package/squircles)
[![TypeScript types included](https://img.shields.io/badge/TypeScript-types_included-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://github.com/eiserv/squircles/blob/main/src/index.ts)
[![MIT license](https://img.shields.io/github/license/eiserv/squircles?style=flat-square&color=666666)](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
```

![The playground with a 200 × 72 button at radius 24: the squircle and the
border-radius button side by side, their outlines overlaid, and the top left
corner magnified](docs/media/playground.png)

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:

![picture lol](docs/media/smoothing-extremes.png)

## 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.
<p align="center">
<img src="docs/media/playground.png" width="640" alt="The neutral playground with controls, shape comparison, and an enlarged corner" />
</p>

## Install

```sh
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 (
<Squircle as="button" type="button" className="button">
Learn more
</Squircle>
);
}
```
## 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
<Squircle as="article" className="card">
<Squircle as="img" className="card__image" src="/photo.jpg" alt="" />
<p>Some text</p>
</Squircle>
```
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);
}
<Squircle as="button" type="button" className="button">
Learn more
</Squircle>
```

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
<script>
import { squircle } from "squircles/svelte";
</script>

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.
<button class="button" use:squircle>Learn more</button>
```

## 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<HTMLElement>(".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 (
<button className="button">
<SquircleSurface
radius={8}
smoothing={1}
fill="var(--button-fill)"
stroke="var(--button-stroke)"
strokeWidth={1}
/>
<span className="button__label">Learn more</span>
</button>
);
}
```

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
<p align="center">
<img src="docs/media/smoothing-extremes.png" width="440" alt="Solid squircle and dashed border-radius outlines with an enlarged corner" />
</p>

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
<http://localhost:5173/>, which shapes its own interface with the library, and
a React version on <http://localhost:5173/react/>. `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.
Loading
Loading