Small Figma-style squircles for TypeScript and React.
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.
- 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-radiusduring SSR and before hydration; - ESM, strict TypeScript, and tree-shakeable entry points.
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:
npm run playgroundAt 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:
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.
npm install squirclesGive the element a class and let CSS do the rest:
import { Squircle } from "squircles/react";
export function CallToAction() {
return (
<Squircle as="button" type="button" className="button">
Learn more
</Squircle>
);
}.button {
background: linear-gradient(#243a8f, #192d73);
border: 1px solid #e5e7eb;
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;
}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:
<Squircle as="article" className="card">
<Squircle as="img" className="card__image" src="/photo.jpg" alt="" />
<p>Some text</p>
</Squircle>.card {
border-radius: 20px;
--squircle-smoothing: 1;
background: white;
border: 1px solid #dce1ed;
box-shadow: 0 12px 32px rgba(25, 45, 115, 0.18);
}The shadow follows the smoothed contour rather than the border-radius one.
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".
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.
import { applySquircle } from "squircles/dom";
const dispose = applySquircle(document.querySelector(".card"));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.
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:
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 root entry point does not import React or touch the DOM:
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 });radius accepts a number or individual corners:
radius={{
topLeft: 24,
topRight: 12,
bottomRight: 24,
bottomLeft: 12,
}}These are deliberate limits, not bugs:
- Focus rings.
outlinealways followsborder-radius. Withoutline-offsetthe mismatch is hard to see; a ring sitting directly on the edge will not match. - Different borders per side. Only
border-top-widthandborder-top-colorare read. - Dashed and dotted borders. Only
border-style: solidis drawn. insetshadows. They are left on the element and keep followingborder-radius.- Semi-transparent borders. The surface's clip edge shows through the
stroke. Use
SquircleSurfacefor 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.
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.
npm install
npm run checknpm run playground builds the package and serves two example pages:
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 for the rendering rationale and constraints.

