Beyond Border-Radius
Implementing iOS-Style Squircles in React with CSS Houdini
Introduction: The Problem with Perfect Circles
As frontend developers, we’ve all used border-radius countless times. It’s simple, it works, and it’s supported everywhere. But have you ever noticed that heavily rounded corners on larger UI elements look… off? Not quite as polished as you’d expect in a premium application?
The issue isn’t with your design skills—it’s mathematics.
Standard CSS border-radius creates circular arcs with constant curvature. At the point where this curved corner meets the straight edge, there’s a discontinuity—the curvature jumps abruptly from zero to 1/r. While this is barely noticeable on small elements, it becomes increasingly jarring at larger sizes, creating a harsh, “cut off” appearance rather than a smooth, organic flow.
Enter squircles—the smooth rounded rectangles that Apple has used throughout iOS since 2013. In this article, I’ll walk you through implementing true squircles in React using CSS Houdini’s Paint API, sharing the technical decisions and mathematical foundations behind our TabSwitcher component.
What Exactly Is a Squircle?
The term “squircle” is a portmanteau of square + circle, but the mathematics behind it are far more elegant than the name suggests.
Mathematical Foundation: The Superellipse
Squircles belong to a family of curves called superellipses (also known as Lamé curves), named after French mathematician Gabriel Lamé (1795-1870).
General Formula:
|x/a|ⁿ + |y/b|ⁿ = 1
Where:
aandbare the semi-major and semi-minor axesnis the exponent that determines the curve’s character
The Shape Spectrum:
n = 1: Diamond (rhombus)n = 2: Circle or ellipsen ≈ 4: Squircle (most common in UI)n = 5.2: Apple’s iOS icon shape (with modifications)n → ∞: Rectangle with sharp corners
The magic happens around n = 4. At this value, you get a shape that:
Has variable curvature that tightens as it approaches the corner’s deepest point
Gradually approaches zero curvature where it meets the straight edges
Creates a mathematically continuous, smooth transition
Looks more “natural” and “organic” to the human eye
Why Apple Chose Squircles
When Apple redesigned iOS 7 in 2013, they made a conscious decision to use squircles for every app icon and UI element. Their reasoning?
Visual Harmony: Squircles maintain better visual balance across different element sizes
Organic Feel: The variable curvature mimics curves found in nature
Premium Perception: The subtle sophistication signals attention to detail
Scalability: Works equally well at 40px and 400px
This wasn’t just a design fad—it became an industry standard. Today, failing to use smooth corners can make an interface feel dated or “cheap” to users familiar with iOS.
The CSS Houdini Solution
So how do we implement squircles on the web? CSS alone can’t create superellipse curves—it’s limited to circles and ellipses for corner rounding. We need something more powerful.
Enter CSS Houdini Paint API
The CSS Houdini Paint API (officially the CSS Painting API Level 1) allows JavaScript to programmatically generate images that are integrated directly into the browser’s rendering engine. Think of it as a way to extend CSS with custom drawing logic.
Key Characteristics:
Worklets: Lightweight scripts that run during the render pipeline, off the main thread
Thread-safe: Designed for parallelism (browsers create 2+ instances)
First-cycle rendering: Included in initial paint, no FOUC (Flash of Unstyled Content)
Hardware-accelerated: Leverages GPU where available
The Workflow:
1. Load worklet → 2. Register paint → 3. Use in CSS → 4. Browser renders
(once, in HTML) (automatic) (via custom properties) (on each frame)
Browser Support Reality Check
Native Support (as of 2024):
✅ Chrome/Edge 65+ (March 2018)
✅ Opera 52+
✅ Safari 15.4+ (March 2022)
❌ Firefox (not yet supported, but polyfill available)
For production use, this covers ~85% of global browser traffic (excluding Firefox). The good news? Squircles degrade gracefully—unsupported browsers simply show elements without the mask, maintaining functionality.
Implementation: TabSwitcher Component
Let’s dive into the actual implementation. Our TabSwitcher component creates an iOS-style segmented control with smooth animated transitions between tabs.
Step 1: Load the Squircle Worklet
First, we load the css-houdini-squircle library in our public/index.html:
<!-- public/index.html -->
<head>
<!-- ... other head content ... -->
<!-- CSS Houdini Squircle -->
<script>
if (”paintWorklet” in CSS) {
CSS.paintWorklet.addModule(
“https://www.unpkg.com/css-houdini-squircle/squircle.min.js”
);
}
</script>
</head>
Key points:
Feature detection:
“paintWorklet” in CSSchecks for supportCDN loading: Using unpkg for automatic updates and caching
Early loading: In
<head>before render to ensure worklet is availableSize: ~3-5KB minified (negligible performance impact)
Step 2: The Container
The outer container establishes the tab switcher’s overall shape:
// src/assets/uiComponents/Navigations/TabSwitcher.js
<div
className=”inline-flex items-center gap-0.5 p-1 relative bg-gray-200”
role=”tablist”
style={{
maskImage: ‘paint(squircle)’,
WebkitMaskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘8px’,
‘--squircle-smooth’: ‘0.35’
}}
>
Breakdown:
maskImage: ‘paint(squircle)’: Invokes our custom paint workletWebkitMaskImage: Webkit prefix for Safari compatibility--squircle-radius: 8px: Corner radius (similar to border-radius)--squircle-smooth: 0.35: Smoothness factor (0.1 = subtle, 1.0 = dramatic)
The maskImage property applies the squircle shape as a mask—anything outside the squircle boundary becomes transparent.
Step 3: The Sliding Indicator
This is where it gets interesting. The active tab indicator uses a different smoothness value for visual emphasis:
<div
className=”absolute h-8 shadow-sm transition-transform duration-500 ease-in-out”
style={{
background: accentColor,
maskImage: ‘paint(squircle)’,
WebkitMaskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘6px’,
‘--squircle-smooth’: ‘0.45’, // More pronounced than container!
width: `calc((100% - ${(tabs.length - 1) * 2}px - 8px) / ${tabs.length})`,
transform: `translateX(calc(${activeTab} * 100% + ${activeTab * 2}px))`,
}}
/>
Design Decisions:
Smaller radius (6px vs 8px): Creates a subtle inset effect—the indicator sits comfortably within the container
Higher smoothness (0.45 vs 0.35): The active indicator has more pronounced smooth corners, making it feel more “special” and drawing focus
Dynamic width calculation: Accounts for gaps between tabs (2px each) and container padding (8px total)
Transform-based animation: Smooth 500ms sliding using CSS transforms (hardware-accelerated)
Step 4: The Tab Buttons
{tabs.map((tab, index) => {
const isActive = activeTab === index;
return (
<button
key={tab?.id || index}
onClick={() => setActiveTab(index)}
className={`relative z-10 flex items-center justify-center gap-1.5
whitespace-nowrap border-none bg-transparent h-8 px-3 w-auto
transition-colors duration-300 ease-in-out overflow-hidden ${
isActive ? “text-white” : “text-gray-400 hover:text-gray-600”
}`}
role=”tab”
aria-selected={isActive}
title={tab?.label}
>
<span className=”text-base”>{tab?.icon}</span>
<span className={`text-xs font-medium whitespace-nowrap
transition-[max-width] duration-500 ease-in-out ${
isActive || showLabelsOnInactive ? ‘max-w-full’ : ‘max-w-0’
}`}>
{tab?.label}
</span>
</button>
);
})}
Technical highlights:
z-10: Ensures buttons sit above the sliding indicatorColor transitions sync with indicator animation (300ms vs 500ms for subtle lag effect)
Label expansion uses
max-widthtransition (more performant thanwidth: auto)Proper ARIA attributes for accessibility
Complete Component
Here’s the full TabSwitcher component:
const TabSwitcher = ({
tabs = [],
activeTab,
setActiveTab,
accentColor = “#3b82f6”,
showLabelsOnInactive = false
}) => {
return (
<div className=”flex items-center justify-start w-full px-6 py-2”>
{/* Container with squircle mask */}
<div
className=”inline-flex items-center gap-0.5 p-1 relative bg-gray-200”
role=”tablist”
style={{
maskImage: ‘paint(squircle)’,
WebkitMaskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘8px’,
‘--squircle-smooth’: ‘0.35’
}}
>
{/* Sliding background with enhanced squircle */}
<div
className=”absolute h-8 shadow-sm transition-transform duration-500 ease-in-out”
style={{
background: accentColor,
maskImage: ‘paint(squircle)’,
WebkitMaskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘6px’,
‘--squircle-smooth’: ‘0.45’,
width: `calc((100% - ${(tabs.length - 1) * 2}px - 8px) / ${tabs.length})`,
transform: `translateX(calc(${activeTab} * 100% + ${activeTab * 2}px))`,
}}
/>
{/* Tab buttons */}
{tabs.map((tab, index) => {
const isActive = activeTab === index;
return (
<button
key={tab?.id || index}
onClick={() => setActiveTab(index)}
className={`relative z-10 flex items-center justify-center gap-1.5
whitespace-nowrap border-none bg-transparent h-8 px-3 w-auto
transition-colors duration-300 ease-in-out overflow-hidden ${
isActive ? “text-white” : “text-gray-400 hover:text-gray-600”
}`}
role=”tab”
aria-selected={isActive}
title={tab?.label}
>
<span className=”text-base”>{tab?.icon}</span>
<span className={`text-xs font-medium whitespace-nowrap
transition-[max-width] duration-500 ease-in-out ${
isActive || showLabelsOnInactive ? ‘max-w-full’ : ‘max-w-0’
}`}>
{tab?.label}
</span>
</button>
);
})}
</div>
</div>
);
};
export default TabSwitcher;
Usage:
import TabSwitcher from ‘@/assets/uiComponents/Navigations/TabSwitcher’;
function ExpenseManagement() {
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{ id: 1, label: “All”, icon: <FilterIcon /> },
{ id: 2, label: “Pending”, icon: <ClockIcon /> },
{ id: 3, label: “Approved”, icon: <CheckIcon /> }
];
return (
<TabSwitcher
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
accentColor=”#10b981”
showLabelsOnInactive={false}
/>
);
}
Understanding the Parameters
The css-houdini-squircle library exposes several CSS custom properties:
–squircle-radius
Type: <length> (px, rem, em, etc.)
Default: 8px
Supports: 1-4 values (like padding)
Controls the corner radius size, similar to border-radius.
‘--squircle-radius’: ‘12px’ // All corners
‘--squircle-radius’: ‘12px 8px’ // Top/bottom, left/right
‘--squircle-radius’: ‘12px 8px 12px 8px’ // Top, right, bottom, left
–squircle-smooth
Type: <number>
Default: 1
Range: 0.1 to 1.0
Controls the smoothness of the curve—essentially how “squircle-y” it is.
Visual effect:
0.1-0.2: Minimal smoothing, closer to circular corners0.3-0.4: Balanced, professional smoothness (our choice)0.5-0.7: Pronounced smooth effect0.8-1.0: Maximum squircle effect, very soft corners
Individual Corner Control
For asymmetric designs:
‘--squircle-radius-top-left’: ‘12px’
‘--squircle-radius-top-right’: ‘12px’
‘--squircle-radius-bottom-left’: ‘4px’
‘--squircle-radius-bottom-right’: ‘4px’
Advanced: Outline Mode
Instead of mask mode, you can use squircles as strokes:
style={{
background: ‘paint(squircle)’,
‘--squircle-radius’: ‘10px’,
‘--squircle-smooth’: ‘0.5’,
‘--squircle-outline’: ‘2px’, // Stroke width
‘--squircle-fill’: ‘#3b82f6’ // Stroke color
}}
Design Philosophy: When to Use Squircles
Squircles aren’t a wholesale replacement for border-radius. Strategic use is key.
✅ Use Squircles When:
Prominent UI elements - Cards, modals, panels that are focal points
Large rounded corners - Where border-radius discontinuity becomes visible (typically >12px radius)
iOS-style interfaces - Especially if targeting iPhone users or matching iOS design language
Premium, polished feel - When attention to detail signals quality
Animated elements - The smooth curves look better in motion
Examples from our codebase:
TabSwitcher navigation (primary interaction element)
ExpenseDetails panels (large containers with 20px radius)
Table headers (subtle premium touch)
❌ Use Standard border-radius When:
Small elements - Buttons, badges, chips with <6px radius (difference is imperceptible)
Maximum browser compatibility - If you can’t use a polyfill for Firefox
Performance-critical - Low-end devices or hundreds of elements
Utility components - Internal tools, admin panels where aesthetics are secondary
Our Hybrid Approach
Our codebase has 239 occurrences of border-radius across 54 files, but only 3 files use squircles. This demonstrates strategic enhancement:
Hero components and primary interactions get squircles
Utility elements and less prominent components use border-radius
Balances performance, compatibility, and visual excellence
Performance Considerations
The Good News
Off-Main-Thread Rendering: Worklets run in parallel with your JavaScript—no blocking
Hardware Accelerated: Browser’s rendering engine handles the worklet, GPU acceleration where available
First-Paint Inclusion: No Flash of Unstyled Content (FOUC)
Better Than Alternatives: More performant than SVG masks, clip-path, or multiple DOM elements
Potential Concerns
Initial Load: ~3-5KB worklet download (one-time, cached)
Bezier Calculations: More complex than simple border-radius, but negligible on modern devices
Browser Support: Requires feature detection and fallback strategy
Performance Testing Results
In our testing:
No measurable FPS impact on 60fps animations (our 500ms tab transitions remain smooth)
Negligible CPU overhead compared to border-radius
No increase in paint time for our use case (3-10 squircle elements per page)
Recommendation: Use squircles freely for visible elements, but avoid on:
Frequently re-rendered elements (e.g., scroll-synchronized animations with 100+ items)
Low-end device targets
Server-side rendered critical content (though it works, border-radius is safer)
Behind the Scenes: How the Worklet Works
For the technically curious, here’s what’s happening inside the css-houdini-squircle worklet:
Internal Algorithm
Distance Scaling: All radii are multiplied by 1.8 internally
This constant creates the characteristic squircle curve
Different from border-radius which uses 1:1 ratio
Bezier Approximation: The superellipse curve is approximated using canvas
bezierCurveTo()methodsThe
--squircle-smoothparameter controls bezier control point distanceValue is multiplied by 10 for curve calculation
Constraint Validation:
Maximum radius automatically capped to prevent overflow
Auto-constrains to half the smallest dimension if needed
Simplified pseudo-code:
// Inside the paint worklet (conceptual)
class SquirclePainter {
paint(ctx, geom, properties) {
const radius = properties.get(’--squircle-radius’);
const smooth = properties.get(’--squircle-smooth’);
// Scale radius for squircle effect
const scaledRadius = radius * 1.8;
// Calculate bezier control points
const controlDistance = smooth * 10;
// Draw path with bezier curves
ctx.beginPath();
ctx.moveTo(scaledRadius, 0);
// Top right corner
ctx.bezierCurveTo(
geom.width - controlDistance, 0,
geom.width, controlDistance,
geom.width, scaledRadius
);
// ... other corners ...
ctx.closePath();
ctx.fill();
}
}
Real-World Impact: Why This Matters
The Subtle Power of Details
Design is a compounding game. Individual refinements might seem trivial, but they accumulate to create an overall perception of quality. Users might not consciously notice your squircles, but they’ll feel the difference.
Studies in design psychology show that:
Softer, organic curves reduce visual stress and feel “warmer”
Attention to subtle details signals trustworthiness and professionalism
Consistency with familiar patterns (iOS) creates subconscious comfort
Industry Adoption
Squircles have moved from Apple-exclusive to industry standard:
Blinkit (Indian grocery delivery) implemented continuous corners across their iOS app
Figma has built-in corner smoothing (value of 60 replicates iOS squircles)
Modern design systems increasingly specify squircles for premium products
The W3C Proposal
There’s active discussion (CSSWG Issue #10653) to add native squircle support to CSS:
/* Proposed syntax */
border-radius: 8px smooth(0.6);
The fact that this is under consideration shows squircles aren’t a fad—they’re becoming a web standard.
Practical Implementation Checklist
Before shipping squircles to production:
Setup
Add worklet loading to
index.htmlwith feature detectionTest in Chrome/Edge, Safari, and Firefox (for fallback)
Verify no console errors in unsupported browsers
Check CDN caching is working (Network tab should show 304 on reload)
Implementation
Use
maskImageandWebkitMaskImagefor compatibilityStart with conservative smoothness values (0.3-0.4)
Test various element sizes to find optimal radius
Ensure proper stacking context (z-index) for layered squircles
Performance
Profile with Chrome DevTools Performance tab
Verify animations run at 60fps
Test on low-end devices or throttled CPU
Limit usage to <20 squircle elements per page initially
Accessibility
Confirm no impact on screen readers (squircles are purely visual)
Verify keyboard navigation still works
Check color contrast isn’t affected by mask
Fallback
Design graceful degradation for Firefox (normal rectangles okay?)
Consider css-paint-polyfill if Firefox support critical
Document browser support in your design system
Advanced Variations
Nested Squircles
Create depth with layered squircles at different smoothness levels:
<div style={{
maskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘20px’,
‘--squircle-smooth’: ‘0.3’
}}>
<div style={{
maskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘16px’,
‘--squircle-smooth’: ‘0.5’ // Inner layer is smoother
}}>
Content
</div>
</div>
Asymmetric Corners
Different radii for visual hierarchy:
style={{
maskImage: ‘paint(squircle)’,
‘--squircle-radius-top-left’: ‘20px’,
‘--squircle-radius-top-right’: ‘20px’,
‘--squircle-radius-bottom-left’: ‘4px’,
‘--squircle-radius-bottom-right’: ‘4px’,
‘--squircle-smooth’: ‘0.4’
}}
Animation-Aware Smoothness
Increase smoothness during animations for more dramatic effect:
const [isAnimating, setIsAnimating] = useState(false);
<div style={{
maskImage: ‘paint(squircle)’,
‘--squircle-radius’: ‘12px’,
‘--squircle-smooth’: isAnimating ? ‘0.7’ : ‘0.35’,
transition: ‘--squircle-smooth 300ms ease-out’
}}>
Note: CSS custom property transitions aren’t widely supported yet, but this technique works in Chrome/Edge.
Comparing Approaches
Alternative Methods We Considered
Before settling on CSS Houdini, we evaluated other approaches:
1. SVG Masks
<svg style={{ position: ‘absolute’, width: 0, height: 0 }}>
<defs>
<clipPath id=”squircle”>
<path d=”M...” />
</clipPath>
</defs>
</svg>
<div style={{ clipPath: ‘url(#squircle)’ }}>...</div>
❌ Requires manually calculating SVG paths for each size
❌ Additional DOM elements
❌ Harder to make responsive
2. Canvas-Based Rendering
const drawSquircle = (ctx, x, y, w, h, r) => {
// Manual bezier curve calculations
};
❌ Requires React canvas library
❌ Breaks semantic HTML
❌ Accessibility issues
3. Multiple Border-Radius Elements
<div className=”corner-tl” />
<div className=”corner-tr” />
{/* ... 4 corner elements ... */}
❌ DOM bloat
❌ Complexity
❌ Still uses circular arcs
CSS Houdini won because it’s:
✅ Dynamic and responsive
✅ Clean, semantic HTML
✅ Performant (off-main-thread)
✅ Easy to maintain
✅ Future-proof (potential native support)
Conclusion: Small Details, Big Impact
Implementing squircles in our TabSwitcher component was more than a visual upgrade—it was a statement about our commitment to craft. In an industry where “good enough” often wins, these subtle refinements separate premium products from mediocre ones.
Key Takeaways:
Squircles are mathematically superior for visual continuity—variable curvature beats constant curvature
CSS Houdini makes them practical for production with off-main-thread rendering
Strategic use matters—enhance primary elements, not everything
Browser support is solid for 85%+ of users, with graceful degradation
The industry is moving this direction—W3C proposal, Figma support, iOS ubiquity
The css-houdini-squircle library weighs just 3-5KB and requires minimal setup. For the visual polish it provides, that’s an exceptional ROI.
As we continue building modern web applications, we should ask: why settle for the limitations of circular corners when we can deliver the organic smoothness users have come to expect from premium interfaces?
Resources & Further Reading
Implementation:
Theory:
W3C Discussion:
Design Philosophy:










