@lisse/svelte
v0.4.0
Published
Svelte action for smooth-cornered (squircle) elements
Maintainers
Readme
@lisse/svelte
Svelte action for smooth-cornered (squircle) elements, powered by Figma's smoothing algorithm.
See Gotchas in the root README for
clip-pathinteraction notes (focus outlines, overflow, scrollbars).
Installation
npm install @lisse/sveltePeer dependency: svelte >= 3.0.0 (works with Svelte 3, 4, and 5).
Quick Start
<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div
use:smoothCorners={{ corners: { radius: 20, smoothing: 0.6 } }}
style="background: #fff; padding: 24px"
>
Hello, squircle
</div>Why an action instead of a component? Svelte actions are the idiomatic way to attach behaviour to existing elements. Unlike React or Vue, there is no wrapper
<div>-- the action attaches directly to your element and inserts the SVG overlay into its parent. This gives you full control over your DOM structure.
smoothCorners Action
The action takes a single SmoothCornersConfig parameter: { corners, effects?, autoEffects? }. The corners property is required and accepts a SmoothCornerOptions object — uniform or per-corner.
Uniform Corners
<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div use:smoothCorners={{ corners: { radius: 24, smoothing: 0.6 } }}>
Content
</div>Per-Corner
<div use:smoothCorners={{
corners: {
topLeft: { radius: 40, smoothing: 0.8 },
topRight: 20,
bottomRight: { radius: 30, smoothing: 0.4, preserveSmoothing: false },
bottomLeft: 0,
},
}}>
Different corners
</div>Effects
<div use:smoothCorners={{
corners: { radius: 24, smoothing: 0.6 },
effects: {
innerBorder: { width: 1, color: "#ffffff", opacity: 0.2 },
shadow: { offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: "#000000", opacity: 0.2 },
},
}}>
Content
</div>Reactive Updates
The action responds to parameter changes automatically. Use reactive declarations to update options:
<script>
import { smoothCorners } from "@lisse/svelte";
let radius = $state(20);
</script>
<input type="range" min="0" max="60" bind:value={radius} />
<div
use:smoothCorners={{ corners: { radius, smoothing: 0.6 } }}
style="background: #3b82f6; padding: 24px; color: #fff"
>
Radius: {radius}
</div>Styling Hooks
The action sets data-slot="smooth-corners" and data-state="pending" | "ready" on the host element. The state flips to "ready" after the first clip-path application:
[data-slot="smooth-corners"][data-state="pending"] { opacity: 0; }The preserveSmoothing option controls how corners behave when adjacent corners compete for space. When true (default), the smoothing curve is preserved even when adjacent corners compete for space -- the radius shrinks instead. When false, the radius is preserved and smoothing is reduced.
Auto Effects (enabled by default)
Lisse clips your element with clip-path, which slices through CSS border and box-shadow. Normally that means you have to remove your CSS styles and rewrite them as SVG-based effect config -- extra work that's easy to forget.
Auto effects removes that step. When the action initialises, the library automatically:
- Reads the element's computed
borderandbox-shadow - Converts them to equivalent SVG effects (
innerBorder,shadow,innerShadow) - Strips the CSS properties so they don't get clipped
- Sets
position: relativeon the parent element if needed - Restores the original CSS and parent position on destroy
This is enabled by default — existing CSS borders and shadows just work.
<!-- The CSS border is automatically converted to an SVG inner border -->
<div
use:smoothCorners={{ corners: { radius: 24 } }}
style="border: 2px solid red; padding: 24px"
>
Content with auto border
</div>Explicit effects win
If you pass effects, they take priority over auto-extracted values per key:
<!-- Explicit innerBorder overrides the CSS border; CSS box-shadow is still auto-extracted -->
<div
use:smoothCorners={{
corners: { radius: 24 },
effects: { innerBorder: { width: 1, color: '#00ff00', opacity: 1 } },
}}
style="border: 2px solid red; box-shadow: 0 4px 12px rgba(0,0,0,0.2)"
>
Content
</div>Disabling auto effects
Set autoEffects: false:
<div use:smoothCorners={{ corners: { radius: 24 }, autoEffects: false }}>
Content
</div>When disabled, CSS borders and shadows are left untouched and no automatic extraction occurs -- the original pre-autoEffects behaviour. You will need to ensure the parent has position: relative yourself if using manual effects.
How CSS properties are mapped
| CSS property | SVG effect | Notes |
|---|---|---|
| border | innerBorder | Width, color, opacity, and style extracted from the top edge. |
| box-shadow (outer) | shadow | All outer shadows (supports multiple). |
| box-shadow (inset) | innerShadow | All inset shadows (supports multiple). |
Limitations
Partial CSS conversion:
| CSS feature | What happens | Why |
|---|---|---|
| Per-side borders | Only the top border is read. All four sides are stripped -- differing sides are lost. | SVG strokes follow a single path and cannot vary width/color per side. |
| dashed, dotted, double, groove, ridge | Supported. Extracted from CSS and rendered as SVG equivalents. | -- |
| inset, outset border styles | Not replicated. Rendered as solid. | These styles rely on per-side light/dark shading that has no SVG equivalent on a continuous path. |
| Multiple box-shadow layers | All shadow layers are extracted and rendered. | -- |
| border-image | Not detected. May be misread as a solid border and stripped incorrectly. | getComputedStyle does not expose border-image in a way that can be reliably parsed into SVG. |
| outline | Not read or stripped. | outline is not clipped by clip-path, so it continues to work -- but it renders as a rectangle, not a squircle. |
| Gradient borders from CSS | Not auto-extracted. CSS gradient borders must be set manually via the GradientConfig API. | CSS border colors are returned as flat rgb()/rgba() values by getComputedStyle, so gradient information is lost. |
Behavioral notes:
- One-time extraction -- CSS is read once on init (not re-evaluated on
update()). This avoids repeatedgetComputedStylecalls on every resize. Use explicit effects in config mode for dynamic values. !importantrules -- inline style overrides can't beat!important. The CSS property stays visible (clipped) alongside the SVG replacement, producing doubled visuals. This is a fundamental limitation of inline style specificity. Move the rule to a non-!importantselector, or useautoEffects: false.- CSS transitions --
borderandbox-shadoware stripped via inline styles, so CSS transitions on those properties won't animate. The library removes these properties to prevent clipped artifacts. UseautoEffects: falseand drive explicit effect props from an animation system instead. doubleminimum width --doubleborders require at least 3pxborder-widthto render as double. Thinner double borders fall back to solid. This matches CSS behaviour where the three lines of adoubleborder need minimum space to be visible.groove/ridgeapproximation -- the dark shade is computed asRGB * 2/3(matching Firefox). The shading is uniform around the squircle (no per-side light direction as CSS does on rectangles) because SVG strokes follow a single continuous path without per-segment color control.
CSS Borders and Shadows
Lisse works by applying a CSS clip-path to the element. This means CSS border, box-shadow, and outline get clipped and will look broken at the corners. With autoEffects enabled (the default), CSS borders and box-shadows are automatically converted to SVG equivalents. You can also use the library's innerBorder, outerBorder, innerShadow, and shadow effect config directly -- these render as SVG overlays that correctly follow the squircle path.
Effects
Effects are rendered as SVG overlays. When using effects, the parent element must have position: relative for correct overlay positioning.
The SVG overlays are absolutely positioned inside the parent element. The library automatically sets position: relative on the parent if it currently has position: static. If you already set position: relative (or absolute/fixed) on the parent, the library leaves it unchanged. When the action is destroyed, the position is restored to its original value -- and if multiple Lisse instances share the same parent, the position is only restored when the last one unmounts.
<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div style="position: relative">
<div use:smoothCorners={{
corners: { radius: 24, smoothing: 0.6 },
effects: {
innerBorder: { width: 1, color: "#ffffff", opacity: 0.3 },
outerBorder: { width: 2, color: "#000000", opacity: 0.1 },
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: 32px; color: #fff">
Card with all effects
</div>
</div>Multiple Shadows
Pass an array of ShadowConfig objects to shadow or innerShadow to render multiple shadow layers:
<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div style="position: relative">
<div use:smoothCorners={{
corners: { radius: 24, smoothing: 0.6 },
effects: {
shadow: [
{ offsetX: 0, offsetY: 2, blur: 4, spread: 0, color: "#000000", opacity: 0.1 },
{ offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: "#000000", opacity: 0.15 },
{ offsetX: 0, offsetY: 24, blur: 48, spread: 0, color: "#000000", opacity: 0.1 },
],
},
}} style="background: #fff; padding: 32px">
Layered shadow
</div>
</div>Gradient Borders
Use a GradientConfig object for the border color property to render a gradient border:
<script>
import { smoothCorners } from "@lisse/svelte";
</script>
<div style="position: relative">
<div use:smoothCorners={{
corners: { radius: 24, smoothing: 0.6 },
effects: {
innerBorder: {
width: 2,
color: {
type: "linear",
angle: 135,
stops: [
{ offset: 0, color: "#667eea" },
{ offset: 1, color: "#764ba2" },
],
},
opacity: 1,
},
},
}} style="background: #fff; padding: 32px">
Gradient border
</div>
</div>Effect Types
BorderConfig
| Property | Type | Description |
|----------|------|-------------|
| width | number | Border width in pixels |
| color | string \| GradientConfig | Border color -- hex string or a gradient configuration |
| opacity | number | Border opacity (0-1) |
| style | BorderStyle | Border style (default: "solid"). One of "solid", "dashed", "dotted", "double", "groove", "ridge". |
| dash | number | Custom dash length for dashed/dotted styles |
| gap | number | Custom gap length for dashed/dotted styles |
| lineCap | "butt" \| "round" \| "square" | Line cap for dashed/dotted strokes. Default: "butt" for dashed, "round" for dotted. |
ShadowConfig
| Property | Type | Description |
|----------|------|-------------|
| offsetX | number | Horizontal offset in pixels |
| offsetY | number | Vertical offset in pixels |
| blur | number | Blur radius in pixels |
| spread | number | Spread distance in pixels |
| color | string | Shadow color (hex) |
| opacity | number | Shadow opacity (0-1) |
Types
SmoothCornersAction
interface SmoothCornersAction {
update(options: SmoothCornersConfig): void;
destroy(): void;
}SmoothCornersConfig
interface SmoothCornersConfig {
corners: SmoothCornerOptions;
effects?: EffectsConfig;
autoEffects?: boolean; // Default: true
}The action also re-exports all core types: SmoothCornerOptions, UniformCornerOptions, PerCornerConfig, CornerConfig, BorderConfig, ShadowConfig, EffectsConfig, GradientStop, LinearGradientConfig, RadialGradientConfig, GradientConfig.
EffectsConfig
interface EffectsConfig {
innerBorder?: BorderConfig;
outerBorder?: BorderConfig;
middleBorder?: BorderConfig;
innerShadow?: ShadowConfig | ShadowConfig[];
shadow?: ShadowConfig | ShadowConfig[];
}Gradient Types
interface GradientStop {
offset: number; // 0 to 1
color: string; // hex color
opacity?: number; // 0 to 1, default 1
}
interface LinearGradientConfig {
type: "linear";
angle?: number; // degrees (CSS convention), default 0 (bottom to top)
stops: GradientStop[];
}
interface RadialGradientConfig {
type: "radial";
cx?: number; // 0-1 relative, default 0.5
cy?: number; // 0-1 relative, default 0.5
r?: number; // 0-1 relative, default 0.5
stops: GradientStop[];
}
type GradientConfig = LinearGradientConfig | RadialGradientConfig;SSR / SvelteKit
The smoothCorners action uses browser APIs (ResizeObserver, DOM manipulation) and only runs on the client. In SvelteKit, actions are automatically client-only -- they run after the element is mounted in the DOM, so no special handling is needed.
For server-side path generation (e.g., generating SVG paths in a +page.server.ts load function), use the DOM-free subpath:
import { generatePath } from "@lisse/core/path";
const d = generatePath(200, 100, { radius: 20, smoothing: 0.6 });This entry point exports only pure functions with no DOM dependencies.
