hyperellipse
CSS corner-shape polyfill. Consistent rendering across browsers.
Native in Chromium · Spec-accurate fallback for Safari and Firefox
9.3 kB gzip · Framework-agnostic · SSR-friendly
Overview
Why hyperellipse?
corner-shape
adds squircles, scoops, and superellipses —
border-radius
can't draw those. Only Chromium supports it today; Safari and Firefox drop
the property at parse time. hyperellipse makes one stylesheet work
everywhere.
One CSS pattern, two paths
Set --corner-shape next to border-radius, call registerHyperellipse() once. Chrome keeps native rendering through a tiny CSS bridge — no observers, no layout thrashing. Everywhere else, a stylesheet scan drives a clip-path fallback.
Matches Chromium
Same border-radius, same squircle in Safari as in Chrome. The fallback tracks CSS Borders 4 superellipse curves instead of hand-rolling clip-path.
Real-world CSS
Gradients, borders, box-shadow, outline with offset — it shapes the box, not an SVG layer on top.
SSR-friendly
Squircles read less round at the same radius. Import hyperellipse/css so SSR doesn't paint a circle before JS kicks in.
Tiny API
One function call. registerHyperellipse() is the whole integration — idempotent, no-op on the server, nothing per-element.
Browser support
Chrome
NativeEdge
NativeSafari
hyperellipseFirefox
hyperellipseInstall
npm install hyperellipseAgent skill
Install the Agent Skill so coding agents on skills.sh integrate hyperellipse correctly — SSR snippet, API, and limitations.
npx skills add mikhailmogilnikov/hyperellipse@hyperellipseQuick start
-
1. Register on the client
Call
registerHyperellipse()once on the client — inApp, root layout, or entry file. Safe to call again; no-op during SSR.'use client'; import { useEffect } from "react"; import { registerHyperellipse } from "hyperellipse"; export default function App() { useEffect(() => { registerHyperellipse(); }, []); return <main>...</main>; } -
2. Style elements
Set
--corner-shapenext toborder-radius— the custom property survives in the CSSOM where nativecorner-shapeis dropped.Tip: add
corner-shape: squirclefor native zero-JS rendering..button { --corner-shape: squircle; border-radius: 45px; } -
3. SSR fallback (recommended)
Squircles look less round than circles at the same radius. Add this global snippet so unsupported browsers set
--corner-scale: 0.6before JS loads.@supports not (corner-shape: squircle) { :root { --corner-scale: 0.6; } } /* or @import "hyperellipse/css"; */ .button { --corner-shape: squircle; border-radius: calc(45px * var(--corner-scale, 1)); }
Reference
API
Supported values
Same shorthand grammar as native
corner-shape
— 1 to 4 values per element.
--corner-shape: squircle;
--corner-shape: superellipse(4);
--corner-shape: squircle bevel scoop notch;
/* keywords: round, squircle, square, bevel, scoop, notch, superellipse(K) */Without a stylesheet
Set
data-corner-shape
on the element when you cannot add a CSS rule.
<div data-corner-shape="squircle" style="border-radius: 32px"></div>registerHyperellipse()
Idempotent and SSR-safe. Repeated calls return the same controller instance.
import { registerHyperellipse } from "hyperellipse";
const controller = registerHyperellipse({
selector: ".card", // extra selectors (cross-origin sheets)
pendingRadiusScale: 0.6, // radius scale after JS loads
force: false, // force fallback in supporting browsers
});
controller.supported; // native corner-shape?
controller.active; // fallback engine running?
controller.refresh(); // rescan + recompute
controller.destroy(); // tear downOptions
selector Extra selectors for elements using corner shapes — escape hatch for cross-origin stylesheets.
pendingRadiusScale Border-radius multiplier in the automatic pending stylesheet after JS loads. Default 0.6. Prefer the CSS --corner-scale snippet for SSR.
force Force the JS fallback even when the browser supports native corner-shape. Useful for debugging.
Controller
supported Whether the browser supports native corner-shape.
active Whether the JS fallback engine is running.
refresh() Rescan stylesheets and recompute every tracked element.
destroy() Stop the fallback and remove all applied inline styles.
How it works
registerHyperellipse() checks for native
corner-shape
. Chromium gets a CSS-only path; Safari and Firefox run the fallback
pipeline below.
Feature detect
CSS.supports('corner-shape') passes. No fallback engine, no observers.
CSS bridge
Injects @property --corner-shape and corner-shape: var(--corner-shape) at zero specificity. Your CSS drives native rendering.
1. Scan
Walks stylesheets for selectors declaring --corner-shape. Also matches [data-corner-shape], inline styles, and any extra selector you pass in.
2. Match
Queries the DOM for matched elements and attaches a shared ResizeObserver to each.
3. Read
Batch-reads computed styles once per animation frame. Temporarily forces --corner-scale: 1 so geometry uses the full radius, not the SSR reduction.
4. Compute
Builds superellipse corner paths from element size, border-radius, and the --corner-shape value.
5. Apply
Solid fills get clip-path: path(...). Borders, shadows, or outlines switch to layer mode — SVG rings and blurred shapes on ::before / ::after.
6. Watch
MutationObserver rescans when stylesheets or attributes change. Pointer enter/leave on the element and its ancestors picks up :hover (including parent selectors); focusin/focusout picks up :focus. transitionrun and transitionend catch the start and end of CSS transitions. A shared IntersectionObserver skips off-screen elements until they enter the viewport. Size-only resizes reuse cached geometry without re-reading computed styles.
Known limitations
Inset shadows
Outer shadows and spread are exact; inset box-shadow is dropped in the fallback.
Non-uniform borders
Dashed, dotted, and per-side borders render as a uniform solid ring.
Layer mode + images
With box-shadow or outline, background images and gradients are not shaped — corners may stick out. Solid fills work fully.
Pseudo-elements & clipping
Layer mode uses ::before/::after and isolation: isolate. Child content is not clipped to the shape.
Dynamic styles
:hover and :focus work in the fallback. The engine re-reads computed styles on pointer enter/leave and focus events on the element and its ancestors — so parent :hover rules (e.g. .wrap:hover .block) are covered too. Call refresh() for imperative updates outside CSS.
CSS transitions
border-radius, box-shadow, and outline transitions are not interpolated frame-by-frame — the shape updates on state change, at transitionrun, and at transitionend. opacity and transform always transition natively. background-color transitions 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 via ResizeObserver.
Non-inherited property
--corner-shape does not inherit — set it on the element itself, not on an ancestor.
CSS keyframe animations
corner-shape, border-radius, box-shadow, and outline keyframes are not tracked frame-by-frame in the fallback. Size changes (width / height) animate smoothly via ResizeObserver.
Examples
Corner shapes
Squircle
.block {
--corner-shape: squircle;
border-radius: 28px;
}Superellipse
.block {
--corner-shape: superellipse(4);
border-radius: 28px;
}Scoop
.block {
--corner-shape: scoop;
border-radius: 28px;
}Notch
.block {
--corner-shape: notch;
border-radius: 28px;
}Per-corner mix
.block {
--corner-shape: squircle bevel scoop notch;
border-radius: 28px;
} Style blocks
Static
Fill
.block {
--corner-shape: squircle;
border-radius: 28px;
}Border
.block {
--corner-shape: squircle;
border-radius: 28px;
border: 2px solid rgb(0 0 0 / 0.2);
}Box shadow
.block {
--corner-shape: squircle;
border-radius: 28px;
box-shadow: 0 8px 20px rgb(0 0 0 / 0.25);
}Outline
.block {
--corner-shape: squircle;
border-radius: 28px;
outline: 2px solid rgb(0 0 0 / 0.25);
outline-offset: 5px;
}Gradient
.block {
--corner-shape: squircle;
border-radius: 28px;
background: linear-gradient(
135deg,
rgb(0 0 0 / 0.15),
rgb(0 0 0 / 0.05)
);
}Pill
.block {
--corner-shape: squircle;
border-radius: 50%;
}Hover
:hover works in Safari and Firefox. Border, shadow, and outline update instantly — not mid-transition.
Hover + border
.block {
--corner-shape: squircle;
border-radius: 28px;
border: 2px solid rgb(0 0 0 / 0.2);
}
.block:hover {
border-width: 4px;
}Hover + shadow
.block {
--corner-shape: squircle;
border-radius: 28px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
}
.block:hover {
box-shadow: 0 10px 24px rgb(0 0 0 / 0.25);
}Hover + outline
.block {
--corner-shape: squircle;
border-radius: 28px;
outline: 2px solid rgb(0 0 0 / 0.25);
outline-offset: 4px;
}
.block:hover {
outline-width: 4px;
outline-offset: 6px;
}Animation
Only size (width / height) is tracked during animation. Other property keyframes are deliberately ignored for performance reasons.
Size + border
@keyframes breathe {
0%,
100% {
width: 3.25rem;
height: 3.25rem;
}
50% {
width: 5.5rem;
height: 5.5rem;
}
}
.block {
--corner-shape: squircle;
border-radius: 28px;
border: 2px solid rgb(0 0 0 / 0.2);
animation: breathe 2.4s ease-in-out infinite;
}Size + shadow
@keyframes stretch {
0%,
100% {
width: 5.5rem;
height: 3.25rem;
}
33% {
width: 3.5rem;
height: 5rem;
}
66% {
width: 5rem;
height: 3.5rem;
}
}
.block {
--corner-shape: squircle;
border-radius: 28px;
box-shadow: 0 8px 20px rgb(0 0 0 / 0.25);
animation: stretch 3s ease-in-out infinite;
}Size + outline
@keyframes breathe {
0%,
100% {
width: 3.25rem;
height: 3.25rem;
}
50% {
width: 5.5rem;
height: 5.5rem;
}
}
.block {
--corner-shape: squircle;
border-radius: 28px;
outline: 2px solid rgb(0 0 0 / 0.25);
outline-offset: 5px;
animation: breathe 2.4s ease-in-out infinite;
} Real-world UI
Primary button
squircle · fill · shadow Secondary button
squircle · border · shadow Search field
squircle · border Icon button
squircle · border Avatar
squircle · fill Badge
squircle · fill List item
squircle · border · shadow Toast
squircle · border · shadow