@lisse/svelte
v0.5.2
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, giving you full control over your DOM structure.
Using it as an attachment (Svelte 5.32+)
Attachments are Svelte 5's modern alternative to actions. You don't need a separate API for them — the action plugs straight into Svelte's built-in fromAction:
<script>
import { smoothCorners } from "@lisse/svelte";
import { fromAction } from "svelte/attachments";
let radius = $state(20);
</script>
<div {@attach fromAction(smoothCorners, () => ({ corners: { radius, smoothing: 0.6 } }))}>
Content
</div>Pass a getter (() => config) as the second argument so reactive changes flow through the action's update() in place — the same efficient path use: takes, with no teardown of the SVG overlay between updates. A static config can simply return a constant. The use: form keeps working unchanged, so existing code (and Svelte 3 / 4) is unaffected.
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 and 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, which is 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. 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 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. !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 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. 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: a 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.
