This is the companion post to Corner Smoothing on the Web. That article covers the math and the tradeoffs. This one is about the components themselves — what they do, how they look, and how to use them.
All components are zero-dependency, available as shadcn registry items, and work with any React setup.
Smoothed is the latest unified version — it auto-detects capsule vs squircle based on the radius and dimensions, supports per-corner radii, and replaces both SmoothedView and SmoothedCapsule in a single import.
pnpm dlx shadcn@latest add https://kit.roomzer.dev/r/smoothed.jsonSmoothedView and SmoothedCapsule are still available as standalone components if you only need one shape:
pnpm dlx shadcn@latest add https://kit.roomzer.dev/r/smoothed-view.jsonpnpm dlx shadcn@latest add https://kit.roomzer.dev/r/smoothed-capsule.jsonWhat they look like
SmoothedView applies Figma-style corner smoothing to any element. It works with borders, background colors, backdrop-blur, box-shadows — anything you’d put on a div.
SmoothedCapsule is the same idea for stadium shapes. The radius is auto-computed from the element’s dimensions, so you just control the smoothing factor and optional border.
Both components play well with other libraries. Here’s a Vaul drawer using SmoothedView for the sheet and SmoothedCapsule for the trigger and handle — with backdrop-blur for that iOS glass panel feel:
Segmented control
SmoothedCapsule with Motion’s layoutId gives you an animated segmented control. The active indicator is a SmoothedCapsule that springs between positions:
Filtered card grid
SmoothedView with AnimatePresence and layout animations. Cards enter, exit, and reflow with staggered springs:
Composability
Both components accept an as prop for rendering as any element or component. Refs are forwarded, so they work with motion.div, button, a, or any custom component.
Smoothed API ✳︎ latest
The unified component. Pass a large radius (e.g. 9999) and it renders a capsule. Pass a smaller radius and it renders a squircle. Mix per-corner radii and it always renders as a squircle.
<Smoothed
radius={24}
smoothing={0.6}
borderWidth={1}
borderColor="var(--color-border)"
as="button"
className="p-4"
>
Content
</Smoothed>
| Prop | Type | Default | Description |
|---|---|---|---|
radius | number | 0 | Base corner radius in px. When ≥ min(width, height) / 2 with uniform corners, auto-switches to capsule mode |
smoothing | number | 0.6 | Corner smoothing factor, 0–1. 0 = standard border-radius, 0.6 = iOS default |
borderWidth | number | 0 | Border width in px, rendered via ::after pseudo-element |
borderColor | string | "transparent" | Any CSS color value |
topLeftRadius | number | — | Per-corner radius override (forces squircle mode) |
topRightRadius | number | — | Per-corner radius override (forces squircle mode) |
bottomRightRadius | number | — | Per-corner radius override (forces squircle mode) |
bottomLeftRadius | number | — | Per-corner radius override (forces squircle mode) |
as | ElementType | "div" | Render as a different element or component |
Plus all standard HTML attributes and ref. The blog version also includes MotionProps in its type, so motion.div via as works with full type inference out of the box.
SmoothedView API
Standalone squircle component. For new projects, consider using
Smoothedinstead — it includes all SmoothedView functionality plus auto capsule detection.
<SmoothedView
radius={24}
smoothing={0.6}
borderWidth={1}
borderColor="var(--color-border)"
as="button"
className="p-4"
>
Content
</SmoothedView>
| Prop | Type | Default | Description |
|---|---|---|---|
radius | number | — | Base corner radius in px (required) |
smoothing | number | 0.6 | Corner smoothing factor, 0–1. 0 = standard border-radius, 0.6 = iOS default |
borderWidth | number | 0 | Border width in px, rendered via ::after pseudo-element |
borderColor | string | "transparent" | Any CSS color value |
topLeftRadius | number | — | Per-corner radius override |
topRightRadius | number | — | Per-corner radius override |
bottomRightRadius | number | — | Per-corner radius override |
bottomLeftRadius | number | — | Per-corner radius override |
as | ElementType | "div" | Render as a different element or component |
Plus all standard HTML attributes (className, style, onClick, etc.) and ref.
SmoothedCapsule API
Standalone capsule component. For new projects, consider using
Smoothedwithradius={9999}instead — same capsule rendering, plus you get squircle mode and per-corner radii for free.
<SmoothedCapsule
smoothing={0.6}
borderWidth={1}
borderColor="var(--color-border)"
as="button"
className="px-4 py-2"
>
Label
</SmoothedCapsule>
| Prop | Type | Default | Description |
|---|---|---|---|
smoothing | number | 0.6 | Corner smoothing factor, 0–1 |
borderWidth | number | 0 | Border width in px |
borderColor | string | "transparent" | Any CSS color value |
as | ElementType | "div" | Render as a different element or component |
No radius prop — the capsule radius is always min(width, height) / 2, computed automatically via ResizeObserver.
Usage with Motion
Both components accept an as prop, so you can pass motion.div directly. To get full type inference on animation props, add MotionProps to the component’s type:
import type { MotionProps } from "motion/react";
export type SmoothedViewProps = SmoothedViewOwnProps &
Omit<React.ComponentPropsWithoutRef<"div">, keyof SmoothedViewOwnProps> & {
ref?: React.Ref<HTMLElement>;
} & MotionProps;
Then use it like any motion component — animate, layoutId, transition, whileHover all work:
import { motion } from "motion/react";
<SmoothedCapsule
as={motion.div}
layoutId="active-tab"
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="bg-white"
/>
The segmented control and card grid demos above both use this pattern.
How it works under the hood
Both components share the same architecture:
Singleton ResizeObserver. Every SmoothedView and SmoothedCapsule on the page shares a single ResizeObserver. The observer maps each element to a callback via a WeakMap, so cleanup is automatic.
Sub-pixel precision. The ResizeObserver reads borderBoxSize (float) instead of clientWidth/clientHeight (integer). This means the clip-path tracks the element’s actual rendered dimensions — including fractional pixels during CSS transitions. Most squircle libraries round to integers, which causes visible edge clipping on elements at sub-pixel widths. The path generator uses toFixed(4), so precision is preserved end-to-end.
LRU path cache. SVG path strings are cached by dimension key. Once a 200×48 capsule at smoothing 0.6 is computed, it’s never recomputed — any other element with the same dimensions reuses the cached path.
Static clip-path. The path is set as clip-path: path('M ...') — a static string that the browser composites like border-radius. No per-frame recalculation, no CSS expression evaluation, no layout thrashing.
Pseudo-element borders. Borders use an ::after element with an evenodd clip path combining the outer and inset inner paths. This avoids the clip-path-clips-borders problem entirely.
Cap Bézier (SmoothedCapsule). Standard squircle constructions produce a flat spot at the capsule apex — the two mirrored short-side Bézier segments meet with zero curvature. SmoothedCapsule replaces them with a single “cap Bézier” that is tangent-continuous with both arcs and passes through the bounding box edge with real curvature. The result is a capsule that looks uniformly smooth from end to end.
Auto shape detection (Smoothed). The unified component checks whether radius >= min(width, height) / 2 with all four corners uniform and the element non-square. If so it switches to the capsule path builder, otherwise it uses the squircle path builder. Setting any per-corner radius override forces squircle mode. This is a zero-cost branch — just a comparison before path generation.
Style injection. A single <style> tag is injected once for the base classes (.smoothed-base, .smoothed-border for Smoothed, .squircle-base, .squircle-border for SmoothedView, .capsule-base, .capsule-border for SmoothedCapsule). No per-instance style tags.
Performance
The previous post showed that CSS shape() with trig functions is catastrophically slower than a static clip-path: path() during dimension-changing animations. But what about corner-shape: superellipse() — the native CSS proposal?
I benchmarked all three approaches across scenarios ranging from gentle (15 elements, no CPU load) to abusive (50 elements morphing under heavy load). CSS superellipse, SmoothedView (squircle path), and SmoothedCapsule (capsule path) are compared head-to-head:
| Test | CSS superellipse | SmoothedView | SmoothedCapsule |
|---|---|---|---|
| Baseline 15el | 120 fps / 0% drop | 120 fps / 0% drop | 120 fps / 0% drop |
| Baseline 50el | 113.5 fps / 0% drop | 90.3 fps / 0.2% drop | 119.8 fps / 0% drop |
| Real-world 30el | 91.2 fps / 0% drop | 81.5 fps / 0% drop | 85.4 fps / 0% drop |
| Real-world 30el fast | 90.5 fps / 0% drop | 90.8 fps / 0% drop | 93.2 fps / 0% drop |
| Layout 15el | 120.1 fps / 0% drop | 120.7 fps / 0% drop | 120 fps / 0% drop |
| Layout 30el | 112.7 fps / 0% drop | 112.8 fps / 0% drop | 112.4 fps / 0% drop |
| Shuffle 30el | 103.6 fps / 0% drop | 101.6 fps / 0% drop | 100.4 fps / 0% drop |
| Morph 15el | 119.4 fps / 0% drop | 119.8 fps / 0% drop | 119.8 fps / 0% drop |
| Morph 30el | 82.5 fps / 4.6% drop | 81.9 fps / 3.4% drop | 80.9 fps / 4% drop |
| Stress 30el heavy | 43.7 fps / 0.9% drop | 42.3 fps / 1.9% drop | 42.6 fps / 1.9% drop |
| Stress 50el morph | 49.3 fps / 10.2% drop | 44.9 fps / 12.1% drop | 48.8 fps / 10.3% drop |
The short version: they’re essentially the same. CSS superellipse has a slight edge under extreme stress (50 elements morphing — a scenario you’d never ship), but in any realistic workload the difference is within noise. The path-based approach runs at 97–100% of native CSS performance while producing a more accurate curve and giving you full control over the smoothing factor, per-corner radii, and capsule shapes that superellipse() can’t express at all.
Gotchas
overflow: visible — both components set overflow: visible on the base element so the ::after border pseudo-element isn’t clipped. If you need overflow hidden, wrap the content in an inner div.
clip-path clips everything — including box-shadow. Inset shadows work fine (they’re inside the clip). Outset shadows need to go on a wrapper element. backdrop-filter works because it applies before clipping.
Per-corner radii — SmoothedView and Smoothed support them, SmoothedCapsule doesn’t (by design — capsules have uniform corners). In Smoothed, setting any per-corner override forces squircle mode even if the base radius is large enough for a capsule.
SSR — the components render their children server-side as a plain element. The clip-path is applied on mount via ref, so there’s a single paint where the element has standard border-radius before the smoothed path kicks in. In practice this is invisible.