hyperellipse
v1.0.5
Published
Transparent polyfill for CSS corner-shape — squircles, superellipses, scoops, notches, and per-corner mixes
Downloads
948
Maintainers
Readme
hyperellipse
Transparent polyfill for the CSS corner-shape property (squircles, superellipses, scoops, notches). Native rendering in browsers that support corner-shape, a high-fidelity JS fallback everywhere else (Safari, Firefox).
The fallback geometry follows the exact superellipse math from the css-borders-4 spec, so corners look the same in Chrome and in the fallback.
Usage
Write a --corner-shape custom property next to your border-radius (unknown native properties are discarded by parsers in non-supporting browsers, so the custom property is the carrier):
.card {
--corner-shape: squircle;
border-radius: 45px;
background: red;
border: 1px solid black;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}Register once on the client:
import { registerHyperellipse } from "hyperellipse";
registerHyperellipse();That's it. For SSR apps, also see Zero-flash SSR fallback below.
- Browsers with native
corner-shapeget a tiny CSS bridge (corner-shape: var(--corner-shape, round)at zero specificity) — rendering is fully native, no JS observers run. - Browsers without it get the fallback: stylesheets are scanned for selectors declaring
--corner-shape, and matching elements are rendered withclip-path/ SVG layers /drop-shadowfilters.
You can also write the native property alongside for zero-JS rendering in Chrome:
.card {
corner-shape: squircle;
--corner-shape: squircle;
border-radius: 45px;
}Supported values
Same grammar as the native shorthand, 1–4 values (top-left, top-right, bottom-right, bottom-left):
--corner-shape: squircle;
--corner-shape: superellipse(4);
--corner-shape: squircle bevel scoop notch;Keywords: round, squircle, square, bevel, scoop, notch, superellipse(K).
Per-element opt-in
Without a stylesheet rule you can mark elements directly:
<div data-corner-shape="squircle" style="border-radius: 32px"></div>
<!-- or -->
<div style="--corner-shape: squircle; border-radius: 32px"></div>API
const controller = registerHyperellipse({
// Extra selectors (escape hatch for cross-origin stylesheets that can't be scanned)
selector: ".card, .button",
// border-radius multiplier shown before the fallback applies (see below). Default 0.6
pendingRadiusScale: 0.6,
// Force the fallback even with native support (debugging / visual comparison)
force: false,
});
controller.supported; // native corner-shape support
controller.active; // JS fallback engine is running
controller.refresh(); // rescan stylesheets, recompute everything
controller.destroy(); // stop and remove all applied stylesCalling registerHyperellipse() repeatedly returns the same controller. SSR-safe (no-op without document).
Zero-flash SSR fallback (recommended)
A squircle visually rounds less than a circle at the same radius, so SSR pages in Safari/Firefox briefly show "too round" corners until the JS bundle loads. JS can't fix that gap — pure CSS can. Write your radius through the --corner-scale multiplier:
.card {
--corner-shape: squircle;
border-radius: calc(45px * var(--corner-scale, 1));
}And add this global snippet (or @import "hyperellipse/css"):
@supports not (corner-shape: squircle) {
:root {
--corner-scale: 0.6;
}
}How it behaves:
- Native browsers:
--corner-scaleis unset → factor1→ full radius, native squircle. Zero flash. - Fallback browsers, before JS: the
@supports notblock activates at first paint → corners render at ×0.6 radius, closely matching the perceived roundness of the future squircle. No layout shift, no JS timing involved. - Fallback browsers, after init: during each read phase the engine temporarily forces
--corner-scale: 1so squircle geometry is computed from the full radius — identical to Chrome. - Runtime shape off: when
--corner-shapeis removed, the engine sets inline--corner-scale: 1so round mode matches Chrome at full radius (not ×0.6 from the SSR snippet).
0.6 is the perceptual equivalence factor between a circle and a squircle (matching corner cut areas). Tune it globally or per element by overriding --corner-scale inside the @supports not block (e.g. 0.5 for superellipse(3+), 0.7 for softer shapes).
For design systems that apply var(--corner-scale) to round tokens globally, co-locate --corner-scale: 0.6 in the same rule as --corner-shape instead of relying on :root alone.
Automatic pending reduction (secondary)
If you write plain border-radius without the multiplier, the library still injects a stylesheet at registration time that scales matched radii down (pendingRadiusScale, default ×0.6) until per-element styles apply. This only kicks in once JS runs, so it covers late-mounted/CSR content but not the SSR-to-bundle gap — prefer the CSS snippet above. Radii already written via var(--corner-scale) are excluded from it automatically.
How the fallback renders
| Element styles | Strategy |
| --- | --- |
| background (color, gradients, images), content clipping | clip-path: path(...) on the element |
| border (uniform, solid) | SVG ring as the top background layer, native border made transparent (layout preserved) |
| box-shadow (outer, with real spread) | Gaussian-blurred shape baked into a static SVG on a ::before pseudo-element |
| outline + outline-offset | SVG ring on an ::after pseudo-element |
When box-shadow or outline are present the element switches to "layer mode" (no clip-path, the background/border/shadows are painted by a single SVG layer on ::before with z-index: -1) because clip-path clips pseudo-elements and filter output. Shadows are deliberately not rendered with filter: drop-shadow() — Safari clips and lags live filters (especially on zoom); a pre-rendered SVG with feGaussianBlur is static, vector and composited like any background image.
Limitations
insetshadows are dropped (outer shadows, includingspread, are exact).- Dashed/dotted/per-side borders render as a uniform solid ring.
box-shadow/outline+ background images/gradients: in layer mode the background image is not shaped (corners stick out). Solid colors are fully supported.- Layer mode sets
isolation: isolate(a stacking context) to contain thez-index: -1pseudo-layer, and uses the element's::before/::after— they must be free. - In layer mode child content is not clipped to the shape (
overflow: hiddenclips to the rect). :hoverand:focuswork in the fallback. The engine re-reads computed styles on pointer enter/leave and focus events on the element and its ancestors — parent:hoverrules (e.g..wrap:hover .block) are covered too. Callrefresh()for imperative updates outside CSS.--corner-shapeis registered withinherits: false(matching the native property) — set it on the element itself, not an ancestor.border-radius,box-shadow, andoutlinetransitions are not interpolated frame-by-frame — the shape updates on state change, attransitionrun, and attransitionend.opacityandtransformalways transition natively.background-colortransitions smoothly on solid fills only (no shadow or outline); in layer mode the fill is baked into SVG and jumps. Size (width/height) transitions animate smoothly viaResizeObserver.- Animating
corner-shape,border-radius,box-shadow, oroutlinein keyframes is not tracked frame-by-frame in the fallback — only size (width/height) animates smoothly.
Performance
One shared ResizeObserver + MutationObserver for all instances, batched read/write phases per animation frame, keyed caching (no DOM writes unless output changed), size-only updates skip computed-style re-reads entirely. A shared IntersectionObserver defers work for off-screen elements until they enter the viewport.
