Lisse (rhymes with lease) is Figma-quality squircle smoothing for the web. Generate smooth-cornered SVG paths and clip-paths with per-corner control, borders, and shadows.
Standard CSS border-radius produces circular arcs at the corners of an element. Designers (and Apple, and Figma) prefer squircles -- corners where the curvature transitions smoothly into the straight edges, creating a more organic, polished shape.
Lisse implements Figma's corner smoothing algorithm in JavaScript. It generates SVG paths and CSS clip-path values that you can apply to any element, with first-class bindings for React, Vue, and Svelte.
- Pixel-perfect reproduction of Figma's squircle algorithm
- Per-corner radius and smoothing control
- Inner, outer, and middle borders with style variants (solid, dashed, dotted, double, groove, ridge)
- Inner shadow and drop shadow effects
- Auto-effects: CSS borders and shadows are automatically converted to SVG equivalents
- Framework bindings for React, Vue, and Svelte
- Lightweight core with zero dependencies
- DOM-free
/pathsubpath export for SSR and edge runtimes - TypeScript-first with full type coverage
- Auto-updates on element resize via shared
ResizeObserver - Tree-shakeable ESM and CJS builds
Each framework binding offers two ways to apply smooth corners:
| Component | Hook / Composable / Action | |
|---|---|---|
| What it does | Renders its own element with smooth corners applied | Applies smooth corners to an existing element you already have |
| When to use | Building new UI from scratch, or when you want a drop-in replacement for a <div> |
You already have an element and want to add smooth corners without changing your DOM structure |
| Effects | Handled automatically (wrapper div is created for you) | You manage the wrapper element yourself (React/Vue) or ensure the parent has position: relative (Svelte) |
If you're starting fresh, the component is simpler. If you're adding smooth corners to existing elements, use the hook/composable/action.
npm install @lisse/reactComponent:
import { SmoothCorners } from "@lisse/react";
function Card() {
return (
<SmoothCorners corners={{ radius: 20, smoothing: 0.6 }} style={{ background: "#fff", padding: 24 }}>
<h2>Hello, squircle</h2>
</SmoothCorners>
);
}Polymorphic as and asChild:
// Render any HTML element (attributes are typed against `as`).
<SmoothCorners as="a" href="/x" corners={{ radius: 12 }}>Link</SmoothCorners>
// Or merge SmoothCorners onto your own element / component without extra wrappers.
<SmoothCorners asChild corners={{ radius: 12 }}>
<MyButton onClick={handle}>Click</MyButton>
</SmoothCorners>Hook:
import { useRef } from "react";
import { useSmoothCorners } from "@lisse/react";
function Card() {
const ref = useRef<HTMLDivElement>(null);
useSmoothCorners(ref, { radius: 20, smoothing: 0.6 });
return <div ref={ref} style={{ background: "#fff", padding: 24 }}>Hello</div>;
}npm install @lisse/vueComposable:
<script setup>
import { ref } from "vue";
import { useSmoothCorners } from "@lisse/vue";
const el = ref(null);
useSmoothCorners(el, { radius: 20, smoothing: 0.6 });
</script>
<template>
<div ref="el" style="background: #fff; padding: 24px">Hello, squircle</div>
</template>Component:
<script setup>
import { SmoothCorners } from "@lisse/vue";
</script>
<template>
<SmoothCorners :corners="{ radius: 20, smoothing: 0.6 }" style="background: #fff; padding: 24px">
<h2>Hello, squircle</h2>
</SmoothCorners>
</template>SmoothCorners also accepts an asChild boolean that clones the single default slot child instead of rendering its own element. Template refs (ref="x") on <SmoothCorners> expose { el, wrapper }.
npm install @lisse/svelte<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div use:smoothCorners={{ corners: { radius: 20, smoothing: 0.6 } }} style="background: #fff; padding: 24px">
Hello, squircle
</div>npm install @lisse/coreimport { generatePath, generateClipPath } from "@lisse/core";
const path = generatePath(200, 200, { radius: 40, smoothing: 0.6 });
// Use in an <svg> element: <path d={path} />
const clipPath = generateClipPath(200, 200, { radius: 40 });
element.style.clipPath = clipPath;Every binding accepts per-corner overrides. Each corner can be a number (radius only, using default smoothing) or a full CornerConfig object:
const options = {
topLeft: { radius: 40, smoothing: 0.8 },
topRight: 20,
bottomRight: { radius: 30, smoothing: 0.4, preserveSmoothing: false },
bottomLeft: 0,
};When adjacent corners compete for space, larger radii are given priority and smaller corners are reduced proportionally.
Lisse clips your element with clip-path, which slices through CSS borders and shadows. The library provides SVG-based replacements that follow the squircle shape perfectly.
All framework bindings support five effects rendered as SVG overlays:
| Effect | Description |
|---|---|
innerBorder |
Border drawn inside the squircle path (clipped to the shape) |
outerBorder |
Border drawn outside the squircle path (masked to the exterior) |
middleBorder |
Border centred on the squircle path (half inside, half outside) |
innerShadow |
Inset shadow inside the squircle |
shadow |
Drop shadow behind the squircle |
<SmoothCorners
corners={{ radius: 24 }}
innerBorder={{ width: 1, color: "#ffffff", opacity: 0.2 }}
outerBorder={{ width: 2, color: "#000000", opacity: 0.1 }}
middleBorder={{ width: 1, color: "#ff0000", opacity: 0.5 }}
innerShadow={{ offsetX: 0, offsetY: 2, blur: 4, spread: 0, color: "#000000", opacity: 0.15 }}
shadow={{ offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: "#000000", opacity: 0.2 }}
style={{ background: "linear-gradient(135deg, #667eea, #764ba2)", padding: 32 }}
>
<p style={{ color: "#fff" }}>Card with all effects</p>
</SmoothCorners>Both shadow and innerShadow accept a single ShadowConfig or an array of ShadowConfig[]. When auto-extracting from CSS, all box-shadow layers are extracted -- not just the first.
<SmoothCorners
corners={{ radius: 24 }}
shadow={[
{ offsetX: 0, offsetY: 2, blur: 4, spread: 0, color: "#000000", opacity: 0.1 },
{ offsetX: 0, offsetY: 8, blur: 24, spread: -4, color: "#000000", opacity: 0.2 },
]}
innerShadow={[
{ offsetX: 0, offsetY: 1, blur: 2, spread: 0, color: "#000000", opacity: 0.1 },
{ offsetX: 0, offsetY: -1, blur: 2, spread: 0, color: "#ffffff", opacity: 0.05 },
]}
style={{ background: "#fff", padding: 32 }}
>
Card with layered shadows
</SmoothCorners>CSS box-shadow with multiple layers is also extracted automatically:
{/* Both shadow layers are extracted and rendered as SVG */}
<SmoothCorners
corners={{ radius: 24 }}
style={{
background: "#fff",
padding: 32,
boxShadow: "0 2px 4px rgba(0,0,0,0.1), 0 8px 24px rgba(0,0,0,0.2)",
}}
>
Auto-extracted multiple shadows
</SmoothCorners>All three border types (innerBorder, outerBorder, middleBorder) support style variants:
| Style | Description |
|---|---|
solid |
Default. Continuous stroke. |
dashed |
Dashed stroke. Customize with dash and gap. |
dotted |
Dotted stroke (round caps by default). Customize with dash and gap. |
double |
Two lines with a gap in the middle. Requires width >= 3. |
groove |
3D grooved effect (darker shade on the outside). |
ridge |
3D ridged effect (darker shade on the inside). |
<SmoothCorners
corners={{ radius: 24 }}
innerBorder={{
width: 4,
color: "#3b82f6",
opacity: 1,
style: "dashed",
dash: 12, // dash length (default: width * 3)
gap: 6, // gap length (default: width * 2)
lineCap: "round", // "butt" | "round" | "square"
}}
>
Dashed border
</SmoothCorners>BorderConfig.color accepts either a hex string or a GradientConfig object, enabling gradient-colored borders on any border type (innerBorder, outerBorder, middleBorder) and any border style (solid, dashed, dotted, double, groove, ridge).
Gradient borders are API-only -- they cannot be auto-extracted from CSS border-image.
Two gradient types are available:
LinearGradientConfig--{ type: "linear", angle?: number, stops: GradientStop[] }. Theangleis in CSS degrees (default0, which is bottom-to-top;90is left-to-right).RadialGradientConfig--{ type: "radial", cx?: number, cy?: number, r?: number, stops: GradientStop[] }. All values are relative (0 to 1), defaulting to0.5.
Each GradientStop is { offset: number, color: string, opacity?: number } where offset ranges from 0 to 1.
For groove and ridge border styles, each stop's color is automatically darkened (via RGB * 2/3) to produce the 3D shading effect.
<SmoothCorners
corners={{ radius: 24 }}
innerBorder={{
width: 2,
color: {
type: "linear",
angle: 135,
stops: [
{ offset: 0, color: "#667eea" },
{ offset: 1, color: "#764ba2" },
],
},
opacity: 1,
}}
style={{ background: "#fff", padding: 32 }}
>
Gradient border
</SmoothCorners>Radial gradient example:
<SmoothCorners
corners={{ radius: 24 }}
outerBorder={{
width: 3,
color: {
type: "radial",
cx: 0.5,
cy: 0.5,
r: 0.7,
stops: [
{ offset: 0, color: "#ff6b6b" },
{ offset: 0.5, color: "#feca57", opacity: 0.8 },
{ offset: 1, color: "#48dbfb" },
],
},
opacity: 1,
style: "dashed",
dash: 8,
gap: 4,
}}
style={{ background: "#1a1a2e", padding: 32, color: "#fff" }}
>
Radial gradient dashed border
</SmoothCorners>By default, Lisse automatically reads your CSS and converts it to SVG equivalents. On mount, the library:
- Reads the element's computed
borderandbox-shadow - Converts them to SVG effects (
innerBorder,shadow,innerShadow) - Strips the CSS properties so they don't get clipped
- Restores the original CSS on unmount
This means elements with existing CSS borders and shadows just work:
{/* CSS border is automatically converted to an SVG inner border */}
<SmoothCorners corners={{ radius: 24 }} style={{ border: "2px solid red" }}>
Content
</SmoothCorners>Explicit effect props take priority over auto-extracted values:
{/* Explicit innerBorder wins over the CSS border */}
<SmoothCorners
corners={{ radius: 24 }}
style={{ border: "2px solid red" }}
innerBorder={{ width: 1, color: "#00ff00", opacity: 1 }}
>
Content
</SmoothCorners>Pass autoEffects={false} (React), :auto-effects="false" (Vue), or autoEffects: false (Svelte). When disabled, CSS borders and shadows are left untouched and no automatic extraction occurs.
| CSS property | SVG effect | Notes |
|---|---|---|
border |
innerBorder |
Width, color, opacity, and style (including dashed, dotted, double, groove, ridge) are extracted from the top edge. |
box-shadow (outer) |
shadow |
All outer shadows (supports multiple). |
box-shadow (inset) |
innerShadow |
All inset shadows (supports multiple). |
Note:
middleBorderandouterBorderhave no CSS equivalent and are only available as explicit props.
| CSS feature | What happens |
|---|---|
| Per-side borders | Only the top border is read. All four sides are stripped. |
inset, outset border styles |
Rendered as solid. |
border-image |
Not detected. Use gradient borders via the API instead. |
outline |
Not read or stripped. |
- Per-side borders -- Only the top border is read during auto-extraction because
getComputedStylereturns per-side values (borderTopWidth,borderTopColor, etc.) and the SVG overlay renders a single uniform border around the entire squircle. If you need different colors per side, use explicit effect props. border-image-- Not detected because CSSborder-imagesyntax is complex (angle units, color spaces, slice semantics). Reliably parsing all variants is not feasible. Use gradient borders via the explicitBorderConfig.colorAPI instead.outline-- Not extracted because CSS outlines don't followborder-radiusin all browsers, and the squircle shape would make standard outlines look incorrect. The library does not attempt to replicate them.- One-time extraction -- CSS effects are read once on mount because continuously polling
getComputedStylewould hurt performance. Changes to CSS borders or shadows after mount will not be reflected. Use explicit effect props for dynamic values. !importantrules -- Cannot be overridden because the library strips effects via inline styles, and!importantstylesheet rules take precedence over inline styles. The CSS property stays visible alongside the SVG replacement. Move the rule to a non-!importantselector, or useautoEffects: false.- CSS transitions -- Stripped properties (
border,box-shadow) will not animate because they are removed from the element and replaced with SVG. The SVG effects themselves are not animated. UseautoEffects: falseand drive explicit effect props instead. doubleborder minimum width -- Requiresborder-width >= 3pxbecause the double style needs space for two lines and a gap between them. Below 3px, the border falls back to solid.groove/ridgeshading -- The dark shade is computed asRGB * 2/3, matching Firefox's algorithm. This may look slightly different from browser CSS rendering on standard rectangles, but produces consistent results across browsers on squircle shapes.- Wrapper div (React/Vue) -- The
<SmoothCorners>component injects a wrapper<div>withposition: relativefor SVG overlay positioning. This can affect flex/grid layouts and child selectors. Use the hook/composable/action approach for full layout control. - Gradient border auto-extraction -- Gradient borders are API-only. CSS
border-imageis not detected or extracted because its syntax (angle units, color spaces, slice semantics) is too complex to reliably parse. Use explicitGradientConfigviaBorderConfig.colorinstead.
Every element managed by Lisse — whether via the React/Vue components, the React/Vue hooks/composables, or the Svelte action — gets two stable attributes:
data-slot="smooth-corners"— present for the lifetime of the binding.data-state="pending" | "ready"— flips to"ready"after the first successful clip-path application.
Use these to mask any first-frame flicker without sprinkling component-specific class names throughout your CSS:
[data-slot="smooth-corners"][data-state="pending"] { opacity: 0; }
[data-slot="smooth-corners"][data-state="ready"] { opacity: 1; transition: opacity 100ms; }The 0.2.0 release consolidates the corner options into a single corners prop / config field across React, Vue, and Svelte:
- <SmoothCorners radius={20} smoothing={0.6} />
+ <SmoothCorners corners={{ radius: 20, smoothing: 0.6 }} />
- <SmoothCorners topLeft={20} topRight={30} />
+ <SmoothCorners corners={{ topLeft: 20, topRight: 30 }} />
- use:smoothCorners={{ radius: 20, smoothing: 0.6 }}
+ use:smoothCorners={{ corners: { radius: 20, smoothing: 0.6 } }}The Svelte action no longer accepts the bare SmoothCornerOptions shape; pass a SmoothCornersConfig ({ corners, effects?, autoEffects? }) instead.
SmoothCornersProps in React is now generic over the element type passed via as, so external callers extending the type need to thread the element parameter (SmoothCornersProps<"a">).
The core package provides a /path subpath export that excludes all DOM-dependent code. Use it in server-side rendering, Node.js scripts, or edge runtimes:
// DOM-free import - safe for SSR, Node.js, edge runtimes
import { generatePath } from "@lisse/core/path";The /path export includes generatePath, generateClipPath, getPathParamsForCorner, distributeAndNormalize, getSVGPathFromPathParams, toRadians, rounded, nextUid, hexToRgb, SVG_NS, and DEFAULT_SHADOW. It excludes createSvgEffects, createDropShadow, and observeResize.
| Package | Description | Docs |
|---|---|---|
@lisse/core |
Framework-agnostic path generation and effects | README |
@lisse/react |
React hook and component | README |
@lisse/vue |
Vue composable and component | README |
@lisse/svelte |
Svelte action | README |
The algorithm is based on Figma's blog post on squircles and produces the same smooth corners you see in Figma's design tool.
A standard border-radius arc is a quarter circle -- the curvature jumps abruptly from zero (along the straight edge) to a fixed value (along the arc). A squircle uses a series of bezier curves that ease into and out of the corner, distributing curvature smoothly across a longer segment of the edge.
The smoothing parameter (0 to 1) controls how far the curvature extends along the edges. At smoothing: 0 the output is identical to a standard border-radius. At smoothing: 1 the curvature occupies the maximum possible edge length.
When preserveSmoothing is true (the default), the algorithm maintains the requested smoothing value even if it means reducing the effective corner radius. When false, the radius is preserved and smoothing is reduced to fit.
The library supports three border positions, each using a different SVG technique to achieve precise placement relative to the squircle path.
Inner border draws the SVG stroke at double the specified width, then clips it to the squircle shape. Because a stroke straddles the path (half inside, half outside), clipping removes the outer half entirely. Only the inner portion remains visible, so innerBorder appears to sit neatly inside the shape.
Outer border also draws the stroke at double width, but instead of clipping it uses an SVG mask. The mask is a white rectangle (fully visible) with a black squircle path cut out of it (fully hidden). This hides the inner half of the stroke and reveals only the outer half. The mask bounds are extended by the border width so the stroke is never cut off at the edges of the SVG.
Middle border is the simplest case. The stroke is drawn at its actual width with no clip or mask applied. It naturally straddles the path, half inside and half outside the squircle.
Drop shadow does not use CSS box-shadow, which would follow the rectangular bounding box and get clipped. Instead, the library generates an actual squircle SVG path expanded by the spread value in all directions. This path is filled with the shadow color, translated by offsetX/offsetY, and blurred using an SVG feGaussianBlur filter. The shadow SVG is positioned behind the element at z-index: -1 using isolation: isolate to create a proper stacking context.
Inner shadow uses an SVG mask with a cutout. A white rectangle defines the visible area, and a black squircle path punched out of it creates the hole. A colored rectangle drawn behind this mask produces the appearance of shadow around the inside edges. The cutout path is adjusted for spread (shrinking the hole) and offset (shifting it). The result is blurred with feGaussianBlur and then clipped to the original squircle shape so nothing leaks outside.
When an array of shadows is provided, the first shadow in the array renders on top (closest to the element). Each shadow gets its own SVG filter element. Shadows are rendered in reverse order in the SVG DOM so that SVG's "later paints on top" rule matches CSS's "first listed is topmost" convention.
When autoEffects strips a CSS border from an element using box-sizing: content-box, removing the border would cause layout shift -- the content area would expand to fill the space the border occupied. To prevent this, the library automatically increases padding by the border width on each side. The original padding values are saved and restored on cleanup.
All Lisse instances share a single ResizeObserver. Callbacks are batched via requestAnimationFrame so that multiple elements resizing in the same frame only trigger one re-render pass. When the last observed element is removed, the observer disconnects automatically.
The SVG overlays (borders, shadows) are absolutely positioned inside an anchor element. The library automatically sets position: relative on this anchor if it has position: static. A ref-counting system ensures that if multiple Lisse instances share the same anchor, the position is only reset to static when the last instance unmounts.
| Function / Export | Package | Description |
|---|---|---|
generatePath(width, height, options) |
core |
Generate an SVG path d string |
generateClipPath(width, height, options) |
core |
Generate a CSS clip-path: path(...) string |
getPathParamsForCorner(params) |
core |
Compute bezier control points for a single corner |
distributeAndNormalize(rect) |
core |
Distribute radii across a rectangle, resolving overlaps |
getSVGPathFromPathParams(input) |
core |
Assemble a full SVG path from corner parameters |
createSvgEffects(anchor) |
core |
Create an SVG overlay for borders and inner shadows |
createDropShadow(anchor) |
core |
Create a path-based drop shadow |
extractAndStripEffects(el) |
core |
Extract CSS border/shadow and convert to SVG effects |
restoreStyles(el, saved) |
core |
Restore stripped CSS border/shadow styles |
observeResize(el, callback) |
core |
Observe element resize with a shared ResizeObserver |
useSmoothCorners(ref, options, effects?) |
react |
React hook for applying smooth corners |
SmoothCorners |
react |
React component with built-in effects |
useSmoothCorners(target, options, effects?) |
vue |
Vue composable for applying smooth corners |
SmoothCorners |
vue |
Vue component with built-in effects |
smoothCorners(node, input) |
svelte |
Svelte action for applying smooth corners |
See individual package READMEs for full API details.
Lisse targets evergreen browsers. The runtime uses clip-path, ResizeObserver, getComputedStyle, and standard SVG APIs. Concretely:
| Browser | Minimum version |
|---|---|
| Chrome / Edge | 79 |
| Firefox | 69 |
| Safari | 13.1 |
Older browsers miss ResizeObserver; Lisse falls back to a no-op observer there, so elements render with their initial size but do not re-sync on resize. Drop-shadow filters and SVG mask features require the listed versions. If you need broader coverage, polyfill ResizeObserver yourself.
Lisse applies clip-path at the DOM level to carve the squircle shape. Because the clip is enforced by the browser on the clipped element and every descendant, there are three interaction quirks worth knowing before you wire it into an existing design system.
- Focus outlines are clipped.
clip-pathcrops focus rings at the squircle edge, so the outline you get on:focus-visibledisappears around the corners. Either push the outline outside the clip withoutline-offset, replace the outline with abox-shadowring on a parent element, or use the auto-extractedinnerBorderas the focus indicator so it follows the squircle. See the wiki Limitations page. - Overflowing descendants are clipped. Children that paint outside the clipped bounds -- a tooltip popping out of a card, a dropdown menu, a hover lift -- are cropped at the squircle edge. If you need overflow, render the overflowing child in a portal or a sibling that is NOT a descendant of the clipped container. See the wiki Limitations page.
- Scrollbars on scrollable children are clipped. A
<div style="overflow: auto">inside a Lisse-clipped container has its scrollbar cropped at the corners, which makes the scroll thumb look chopped. Move the scroll container outside the clipped element, or wrap the scroll container itself with Lisse rather than a parent. See the wiki Limitations page.
Contributor and release docs live in docs/. Start with docs/publishing.md for the release process and known quirks.