Skip to content

Commit 530fa2f

Browse files
bejarcodeclaude
andcommitted
feat: Add squircle border support with pseudo-element rendering
Implement borderWidth and borderColor options for squircles using a pseudo-element layering approach to work around clip-path masking. Architecture: - ::before pseudo-element renders the border (z-index: 0) - ::after pseudo-element renders the background (z-index: 1) - Content elements are positioned on top (z-index: 2) Changes: - Add borderWidth and borderColor to SquircleConfig interface - Implement pseudo-element-based border rendering in ClipPathRenderer - Capture and preserve element backgrounds using individual CSS properties - Add interactive border controls to playground (toggle, width, color) - Update code generation to include border configuration Technical details: - Split background into separate CSS variables (color, image, size, position, repeat) - Use positive z-index values to ensure proper stacking context - Store original background in dataset to prevent recapture issues - Border extends outward from element boundaries (outer stroke positioning) Performance: Border rendering adds ~0.4ms overhead with no visual artifacts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0f30948 commit 530fa2f

File tree

6 files changed

+466
-23
lines changed

6 files changed

+466
-23
lines changed

packages/core/src/core/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export interface SquircleConfig {
2828
*/
2929
smoothing: number;
3030

31+
/**
32+
* Optional: Border width in pixels
33+
* Creates a squircle-shaped border using ::before pseudo-element
34+
* @minimum 0
35+
* @optional
36+
*/
37+
borderWidth?: number;
38+
39+
/**
40+
* Optional: Border color (any valid CSS color)
41+
* Only used when borderWidth is specified
42+
* @optional
43+
*/
44+
borderColor?: string;
45+
3146
/**
3247
* Optional: Force specific renderer tier
3348
* Normally auto-detected, but can be overridden for testing
@@ -45,7 +60,7 @@ export type PartialSquircleConfig = Partial<SquircleConfig>;
4560
/**
4661
* Default configuration values
4762
*/
48-
export const DEFAULT_CONFIG: Readonly<Required<Omit<SquircleConfig, 'tier'>>> = {
63+
export const DEFAULT_CONFIG: Readonly<Required<Pick<SquircleConfig, 'radius' | 'smoothing'>>> = {
4964
radius: 20, // iOS-typical size for buttons
5065
smoothing: 0.8, // iOS-like appearance (n ≈ 2.4)
5166
} as const;

packages/core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,15 @@ export default class CornerKit {
437437
validatedConfig.smoothing = validateSmoothing(config.smoothing);
438438
}
439439

440+
// Handle border properties
441+
if (config.borderWidth !== undefined) {
442+
validatedConfig.borderWidth = config.borderWidth;
443+
}
444+
445+
if (config.borderColor !== undefined) {
446+
validatedConfig.borderColor = config.borderColor;
447+
}
448+
440449
// Allow tier override (for advanced users)
441450
if (config.tier !== undefined) {
442451
validatedConfig.tier = config.tier;

packages/core/src/renderers/clippath.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,60 @@ export interface ResizeObserverWithCleanup extends ResizeObserver {
3636
* FR-018 to FR-022: SVG clip-path implementation with ResizeObserver
3737
*/
3838
export class ClipPathRenderer {
39+
private static borderStylesInjected = false;
40+
41+
/**
42+
* Inject global CSS styles for squircle borders (once per page)
43+
* Uses ::before for border layer, ::after for content background
44+
* Main element has NO clip-path to allow pseudo-elements to show
45+
*/
46+
private static injectBorderStyles(): void {
47+
if (this.borderStylesInjected || typeof document === 'undefined') {
48+
return;
49+
}
50+
51+
const style = document.createElement('style');
52+
style.id = 'cornerkit-border-styles';
53+
style.textContent = `
54+
[data-squircle-border]::before {
55+
content: '';
56+
position: absolute;
57+
top: calc(var(--squircle-border-width, 0px) * -1);
58+
left: calc(var(--squircle-border-width, 0px) * -1);
59+
width: calc(100% + var(--squircle-border-width, 0px) * 2);
60+
height: calc(100% + var(--squircle-border-width, 0px) * 2);
61+
background: var(--squircle-border-color, transparent);
62+
clip-path: var(--squircle-border-path);
63+
z-index: 0;
64+
pointer-events: none;
65+
border-radius: 0;
66+
}
67+
[data-squircle-border]::after {
68+
content: '';
69+
position: absolute;
70+
top: 0;
71+
left: 0;
72+
width: 100%;
73+
height: 100%;
74+
background-color: var(--squircle-content-bg-color, transparent);
75+
background-image: var(--squircle-content-bg-image, none);
76+
background-size: var(--squircle-content-bg-size, auto);
77+
background-position: var(--squircle-content-bg-position, 0% 0%);
78+
background-repeat: var(--squircle-content-bg-repeat, repeat);
79+
clip-path: var(--squircle-content-path);
80+
z-index: 1;
81+
pointer-events: none;
82+
border-radius: 0;
83+
}
84+
[data-squircle-border] > * {
85+
position: relative;
86+
z-index: 2;
87+
}
88+
`;
89+
document.head.appendChild(style);
90+
this.borderStylesInjected = true;
91+
}
92+
3993
/**
4094
* FR-018: Apply squircle clip-path to an element
4195
* Generates SVG path and sets element.style.clipPath
@@ -101,6 +155,9 @@ export class ClipPathRenderer {
101155
remove(element: HTMLElement, originalTransition?: string): void {
102156
element.style.clipPath = '';
103157

158+
// Remove border properties
159+
this.removeBorderProperties(element);
160+
104161
// Restore original transition if provided
105162
if (originalTransition !== undefined) {
106163
element.style.transition = originalTransition;
@@ -115,7 +172,7 @@ export class ClipPathRenderer {
115172
* @param config - Squircle configuration
116173
*/
117174
private updateClipPath(element: HTMLElement, config: SquircleConfig): void {
118-
const { radius, smoothing } = config;
175+
const { radius, smoothing, borderWidth, borderColor } = config;
119176

120177
// Get current element dimensions
121178
const width = element.offsetWidth;
@@ -129,8 +186,83 @@ export class ClipPathRenderer {
129186
// Generate SVG path string
130187
const path = generateSquirclePath(width, height, radius, smoothing);
131188

132-
// Apply clip-path CSS property
133-
element.style.clipPath = `path('${path}')`;
189+
// Handle border rendering via ::before and ::after pseudo-elements
190+
if (borderWidth && borderWidth > 0 && borderColor) {
191+
// Inject global border styles (once per page)
192+
ClipPathRenderer.injectBorderStyles();
193+
194+
// DON'T apply clip-path to main element - let pseudo-elements handle it
195+
// This prevents the parent's clip-path from cutting off the border
196+
element.style.clipPath = 'none';
197+
198+
// Calculate border path (for the ::before element which is larger)
199+
const borderElementWidth = width + borderWidth * 2;
200+
const borderElementHeight = height + borderWidth * 2;
201+
const borderPath = generateSquirclePath(
202+
borderElementWidth,
203+
borderElementHeight,
204+
radius + borderWidth, // Increase radius proportionally
205+
smoothing
206+
);
207+
208+
// Capture original background ONLY ONCE (before we set it to transparent)
209+
// This prevents recapturing the transparent value on subsequent updates
210+
if (!element.dataset['squircleOriginalBg']) {
211+
const computedStyle = getComputedStyle(element);
212+
213+
// Store individual background properties in CSS variables
214+
element.style.setProperty('--squircle-content-bg-color', computedStyle.backgroundColor);
215+
element.style.setProperty('--squircle-content-bg-image', computedStyle.backgroundImage);
216+
element.style.setProperty('--squircle-content-bg-size', computedStyle.backgroundSize);
217+
element.style.setProperty('--squircle-content-bg-position', computedStyle.backgroundPosition);
218+
element.style.setProperty('--squircle-content-bg-repeat', computedStyle.backgroundRepeat);
219+
220+
// Mark as captured
221+
element.dataset['squircleOriginalBg'] = 'captured';
222+
}
223+
224+
// Set CSS custom properties for pseudo-elements (border path updates on resize)
225+
element.style.setProperty('--squircle-border-width', `${borderWidth}px`);
226+
element.style.setProperty('--squircle-border-color', borderColor);
227+
element.style.setProperty('--squircle-border-path', `path('${borderPath}')`);
228+
element.style.setProperty('--squircle-content-path', `path('${path}')`);
229+
230+
// Make main element's background transparent (::after will show it)
231+
element.style.background = 'transparent';
232+
233+
// Mark element for border styling and ensure position context
234+
element.dataset['squircleBorder'] = 'true';
235+
const computedStyle = getComputedStyle(element);
236+
const computedPosition = computedStyle.position;
237+
if (computedPosition === 'static') {
238+
element.style.position = 'relative';
239+
}
240+
} else {
241+
// No borders - apply clip-path directly to element
242+
element.style.clipPath = `path('${path}')`;
243+
// Remove border properties if not configured
244+
this.removeBorderProperties(element);
245+
}
246+
}
247+
248+
/**
249+
* Remove border-related CSS properties from element
250+
* @param element - Target HTMLElement
251+
*/
252+
private removeBorderProperties(element: HTMLElement): void {
253+
element.style.removeProperty('--squircle-border-width');
254+
element.style.removeProperty('--squircle-border-color');
255+
element.style.removeProperty('--squircle-border-path');
256+
element.style.removeProperty('--squircle-content-path');
257+
element.style.removeProperty('--squircle-content-bg-color');
258+
element.style.removeProperty('--squircle-content-bg-image');
259+
element.style.removeProperty('--squircle-content-bg-size');
260+
element.style.removeProperty('--squircle-content-bg-position');
261+
element.style.removeProperty('--squircle-content-bg-repeat');
262+
delete element.dataset['squircleBorder'];
263+
delete element.dataset['squircleOriginalBg'];
264+
// Note: We don't restore element.style.background here because
265+
// the element will get the standard clip-path instead
134266
}
135267

136268
/**

0 commit comments

Comments
 (0)