@mks2508/mks-ui 0.5.4 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/react-ui/primitives/waapi/Gooey/Gooey.types.d.ts +21 -4
  2. package/dist/react-ui/primitives/waapi/Gooey/Gooey.types.d.ts.map +1 -1
  3. package/dist/react-ui/primitives/waapi/Gooey/GooeyCanvas.d.ts +2 -2
  4. package/dist/react-ui/primitives/waapi/Gooey/GooeyCanvas.d.ts.map +1 -1
  5. package/dist/react-ui/primitives/waapi/Gooey/GooeyCanvas.js +163 -32
  6. package/dist/react-ui/primitives/waapi/Gooey/gooey-utils.d.ts +7 -0
  7. package/dist/react-ui/primitives/waapi/Gooey/gooey-utils.d.ts.map +1 -1
  8. package/dist/react-ui/primitives/waapi/Gooey/gooey-utils.js +6 -1
  9. package/dist/react-ui/ui/DynamicToggle/{DynamicToggle-Cm6-VceQ.css → DynamicToggle-DOR3Ld-k.css} +104 -32
  10. package/dist/react-ui/ui/DynamicToggle/DynamicToggle.css +105 -32
  11. package/dist/react-ui/ui/DynamicToggle/DynamicToggle.styles.js +2 -2
  12. package/dist/react-ui/ui/DynamicToggle/DynamicToggle.types.d.ts +6 -0
  13. package/dist/react-ui/ui/DynamicToggle/DynamicToggle.types.d.ts.map +1 -1
  14. package/dist/react-ui/ui/DynamicToggle/index.d.ts.map +1 -1
  15. package/dist/react-ui/ui/DynamicToggle/index.js +23 -5
  16. package/package.json +1 -1
  17. package/src/react-ui/primitives/waapi/Gooey/Gooey.types.ts +21 -3
  18. package/src/react-ui/primitives/waapi/Gooey/GooeyCanvas.tsx +177 -40
  19. package/src/react-ui/primitives/waapi/Gooey/gooey-utils.ts +9 -0
  20. package/src/react-ui/ui/DynamicToggle/DynamicToggle.css +105 -32
  21. package/src/react-ui/ui/DynamicToggle/DynamicToggle.styles.ts +2 -2
  22. package/src/react-ui/ui/DynamicToggle/DynamicToggle.types.ts +6 -0
  23. package/src/react-ui/ui/DynamicToggle/index.tsx +30 -8
  24. package/src/react-ui/ui/DynamicToggle/prototype-v7-ios.html +413 -0
  25. package/src/react-ui/ui/DynamicToggle/prototype-v8-gooey-safari.html +560 -0
  26. package/src/react-ui/ui/DynamicToggle/prototype-v8b-react-structure.html +227 -0
@@ -12,6 +12,8 @@
12
12
  --dt-dur: 0.22s;
13
13
  --dt-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
14
14
  --dt-fade: 0.45;
15
+ --dt-indicator-dur: 0.3s;
16
+ --dt-indicator-ease: cubic-bezier(0.4, 0, 0.2, 1);
15
17
  }
16
18
 
17
19
  /* ── Track: explicit row prevents h-full items from overflowing container ── */
@@ -24,18 +26,9 @@
24
26
  grid-column: span 2;
25
27
  }
26
28
 
27
- /* ── Main indicator slide ── */
28
- [data-slot="dt-root"] [data-slot="dt-indicator"] {
29
- transition: translate var(--dt-dur) var(--dt-ease);
30
- translate: 100% 0;
31
- }
32
- [data-slot="dt-root"] [data-slot="dt-track"]:has(> input:checked) [data-slot="dt-indicator"] {
33
- translate: 0 0;
34
- }
35
-
36
29
  /* ── Primary option text ── */
37
30
  [data-slot="dt-root"] [data-slot="dt-track"]:has(> input:checked) > label {
38
- color: var(--card);
31
+ color: var(--accent-foreground);
39
32
  z-index: 2;
40
33
  }
41
34
  [data-slot="dt-root"] [data-slot="dt-track"]:not(:has(> input:checked)) > label {
@@ -49,31 +42,109 @@
49
42
  overflow: hidden;
50
43
  }
51
44
 
52
- /* ── Group indicator: clip-path reveal ── */
53
- [data-slot="dt-root"] [data-slot="dt-group-indicator"] {
54
- pointer-events: none;
45
+ /* ══════════════════════════════════════════════════════════
46
+ * INDICATOR POSITIONING
47
+ *
48
+ * Modern: CSS Anchor Positioning — indicator follows active option
49
+ * Fallback: translate-based positioning for older browsers
50
+ * ══════════════════════════════════════════════════════════ */
51
+
52
+ /* ── Anchor-based indicator (requires full anchor API) ── */
53
+ @supports (anchor-scope: all) {
54
+ /* Scope anchors per toggle instance */
55
+ [data-slot="dt-root"]:not([data-indicator="translate"]) {
56
+ anchor-scope: --dt-active;
57
+ }
58
+
59
+ /* Active option becomes the anchor via native :checked */
60
+ [data-slot="dt-root"]:not([data-indicator="translate"]) [data-slot="dt-track"] > label:has(+ input:checked) {
61
+ anchor-name: --dt-active;
62
+ }
63
+ [data-slot="dt-root"]:not([data-indicator="translate"]) [data-slot="dt-group"] > label:has(+ input:checked) {
64
+ anchor-name: --dt-active;
65
+ }
66
+
67
+ /* Single unified indicator: morphs from full-width to half-width */
68
+ [data-slot="dt-root"]:not([data-indicator="translate"]) [data-slot="dt-indicator"] {
69
+ position-anchor: --dt-active;
70
+ top: anchor(top);
71
+ right: anchor(right);
72
+ bottom: anchor(bottom);
73
+ left: anchor(left);
74
+ translate: none;
75
+ width: auto;
76
+ transition:
77
+ top var(--dt-indicator-dur) var(--dt-indicator-ease),
78
+ right var(--dt-indicator-dur) var(--dt-indicator-ease),
79
+ bottom var(--dt-indicator-dur) var(--dt-indicator-ease),
80
+ left var(--dt-indicator-dur) var(--dt-indicator-ease);
81
+ }
82
+
83
+ /* Hide the group indicator — unified indicator handles everything */
84
+ [data-slot="dt-root"]:not([data-indicator="translate"]) [data-slot="dt-group-indicator"] {
85
+ display: none;
86
+ }
87
+ }
88
+
89
+ /* ── Inset-based fallback (older browsers) — same morph as anchor but hardcoded ── */
90
+ @supports not (anchor-scope: all) {
91
+ /* Unified indicator: left/right transition morphs width + position */
92
+ [data-slot="dt-root"] [data-slot="dt-indicator"] {
93
+ left: 50%;
94
+ right: 0;
95
+ width: auto;
96
+ translate: none;
97
+ transition:
98
+ left var(--dt-indicator-dur) var(--dt-indicator-ease),
99
+ right var(--dt-indicator-dur) var(--dt-indicator-ease);
100
+ }
101
+ /* Top-level checked: indicator covers left half */
102
+ [data-slot="dt-root"] [data-slot="dt-track"]:has(> input:checked) [data-slot="dt-indicator"] {
103
+ left: 0;
104
+ right: 50%;
105
+ }
106
+ /* Group option 1 checked: indicator at 3rd quarter */
107
+ [data-slot="dt-root"] [data-slot="dt-group"]:has(input:nth-of-type(1):checked) ~ [data-slot="dt-indicator"],
108
+ [data-slot="dt-root"] [data-slot="dt-track"]:has([data-slot="dt-group"] input:nth-of-type(1):checked) [data-slot="dt-indicator"] {
109
+ left: 50%;
110
+ right: 25%;
111
+ }
112
+ /* Group option 2 checked: indicator at 4th quarter */
113
+ [data-slot="dt-root"] [data-slot="dt-group"]:has(input:nth-of-type(2):checked) ~ [data-slot="dt-indicator"],
114
+ [data-slot="dt-root"] [data-slot="dt-track"]:has([data-slot="dt-group"] input:nth-of-type(2):checked) [data-slot="dt-indicator"] {
115
+ left: 75%;
116
+ right: 0;
117
+ }
118
+ /* Hide group indicator — unified indicator handles everything */
119
+ [data-slot="dt-root"] [data-slot="dt-group-indicator"] {
120
+ display: none;
121
+ }
122
+ }
123
+
124
+ /* ── Force inset mode via data-indicator="translate" (works regardless of @supports) ── */
125
+ [data-slot="dt-root"][data-indicator="translate"] [data-slot="dt-indicator"] {
126
+ left: 50%;
127
+ right: 0;
128
+ width: auto;
129
+ translate: none;
55
130
  transition:
56
- translate var(--dt-dur) var(--dt-ease),
57
- clip-path var(--dt-dur) var(--dt-ease),
58
- background var(--dt-dur) var(--dt-ease);
59
- clip-path: inset(
60
- 73cqh calc(50% + 1px) calc(27cqh - 2px) calc(50% - 3px)
61
- round var(--dt-radius, 9999px)
62
- );
63
- translate: -50% 0;
64
- }
65
- [data-slot="dt-root"] [data-slot="dt-track"]:has(> input:checked) [data-slot="dt-group-indicator"] {
66
- background: transparent;
131
+ left var(--dt-indicator-dur) var(--dt-indicator-ease),
132
+ right var(--dt-indicator-dur) var(--dt-indicator-ease);
67
133
  }
68
- [data-slot="dt-root"] [data-slot="dt-group"]:has(input:checked) [data-slot="dt-group-indicator"] {
69
- background: var(--card);
70
- clip-path: inset(0 0 0 0 round var(--dt-radius, 9999px));
134
+ [data-slot="dt-root"][data-indicator="translate"] [data-slot="dt-track"]:has(> input:checked) [data-slot="dt-indicator"] {
135
+ left: 0;
136
+ right: 50%;
71
137
  }
72
- [data-slot="dt-root"] [data-slot="dt-group"]:has(input:nth-of-type(1):checked) [data-slot="dt-group-indicator"] {
73
- translate: -100% 0;
138
+ [data-slot="dt-root"][data-indicator="translate"] [data-slot="dt-track"]:has([data-slot="dt-group"] input:nth-of-type(1):checked) [data-slot="dt-indicator"] {
139
+ left: 50%;
140
+ right: 25%;
74
141
  }
75
- [data-slot="dt-root"] [data-slot="dt-group"]:has(input:nth-of-type(2):checked) [data-slot="dt-group-indicator"] {
76
- translate: 0 0;
142
+ [data-slot="dt-root"][data-indicator="translate"] [data-slot="dt-track"]:has([data-slot="dt-group"] input:nth-of-type(2):checked) [data-slot="dt-indicator"] {
143
+ left: 75%;
144
+ right: 0;
145
+ }
146
+ [data-slot="dt-root"][data-indicator="translate"] [data-slot="dt-group-indicator"] {
147
+ display: none;
77
148
  }
78
149
 
79
150
  /* ══════════════════════════════════════════════════════════
@@ -233,6 +304,8 @@
233
304
  background: var(--card);
234
305
  border: 1px solid var(--border);
235
306
  z-index: 3;
307
+ transform: translateZ(0);
308
+ -webkit-transform: translateZ(0);
236
309
  }
237
310
  [data-slot="dt-group-label"] > span {
238
311
  overflow: hidden;
@@ -300,4 +373,4 @@
300
373
  [data-slot="dt-root"][data-morph="path"] [data-slot="dt-track"] {
301
374
  position: relative;
302
375
  z-index: 1;
303
- }
376
+ }
@@ -30,9 +30,9 @@ export const dynamicToggleStyles: StyleSlots<DynamicToggleSlot> = {
30
30
  root: 'relative border p-[2px] select-none',
31
31
  track: 'relative grid grid-cols-[repeat(4,1fr)] place-items-center w-full h-full',
32
32
  option: 'inline-grid place-items-center cursor-pointer font-medium z-[2] h-full w-full whitespace-nowrap',
33
- indicator: 'absolute w-1/2 left-0 top-0 bottom-0 bg-foreground rounded-[var(--dt-radius,9999px)] pointer-events-none z-0',
33
+ indicator: 'absolute w-1/2 left-0 top-0 bottom-0 bg-accent rounded-[var(--dt-radius,9999px)] pointer-events-none z-0',
34
34
  group: 'col-span-2 relative w-full h-full grid grid-cols-2',
35
- groupIndicator: 'absolute left-1/2 top-0 bottom-0 w-1/2 bg-foreground rounded-[var(--dt-radius,9999px)] pointer-events-none z-0',
35
+ groupIndicator: 'absolute left-1/2 top-0 bottom-0 w-1/2 bg-accent rounded-[var(--dt-radius,9999px)] pointer-events-none z-0',
36
36
  groupLabel: [
37
37
  'absolute',
38
38
  'flex items-center justify-center',
@@ -42,6 +42,12 @@ export interface IDynamicToggleConfig extends IBaseConfig {
42
42
  labelAnimation?: 'morph' | 'float' | 'none';
43
43
  /** Gooey morph mode for the pill↔groupLabel junction (default: 'none') */
44
44
  morphMode?: DynamicToggleMorphMode;
45
+ /** Indicator slide duration in seconds (default: 0.3) */
46
+ indicatorDuration?: number;
47
+ /** Indicator slide easing (default: material standard cubic-bezier) */
48
+ indicatorEasing?: string;
49
+ /** Force translate-based indicator instead of CSS Anchor (debug/compat) */
50
+ forceTranslateIndicator?: boolean;
45
51
  }
46
52
 
47
53
  // ---------------------------------------------------------------------------
@@ -62,6 +62,18 @@ const SIZE_HEIGHT_PX: Record<string, number> = {
62
62
  lg: 44,
63
63
  };
64
64
 
65
+ const SIZE_WIDTH_PX: Record<string, number> = {
66
+ sm: 210,
67
+ default: 260,
68
+ lg: 320,
69
+ };
70
+
71
+ const SHAPE_RADIUS: Record<string, number> = {
72
+ pill: 9999,
73
+ rounded: 12,
74
+ square: 6,
75
+ };
76
+
65
77
  // ---------------------------------------------------------------------------
66
78
  // DynamicToggle (Root)
67
79
  // ---------------------------------------------------------------------------
@@ -115,9 +127,12 @@ function DynamicToggle({
115
127
  const showGroupLabel = labelAnimation !== 'none' && groupPosition !== 'hidden' && groupLabel;
116
128
  const heightPx = SIZE_HEIGHT_PX[size ?? 'default'] ?? 32;
117
129
 
118
- const style = config?.duration
119
- ? { '--dt-dur': `${config.duration}s` } as React.CSSProperties
120
- : undefined;
130
+ const style = {
131
+ ...(config?.duration && { '--dt-dur': `${config.duration}s` }),
132
+ ...(config?.indicatorDuration && { '--dt-indicator-dur': `${config.indicatorDuration}s` }),
133
+ ...(config?.indicatorEasing && { '--dt-indicator-ease': config.indicatorEasing }),
134
+ } as React.CSSProperties;
135
+ const hasStyle = Object.keys(style).length > 0;
121
136
 
122
137
  // Group label element (shared between modes)
123
138
  const groupLabelElement = showGroupLabel ? (
@@ -135,19 +150,26 @@ function DynamicToggle({
135
150
  <div
136
151
  data-slot="dt-root"
137
152
  data-morph={effectiveMorphMode !== 'none' ? effectiveMorphMode : undefined}
153
+ data-indicator={config?.forceTranslateIndicator ? 'translate' : undefined}
138
154
  data-group-active={groupActive || undefined}
139
155
  data-disabled={disabled || undefined}
140
156
  role="radiogroup"
141
157
  aria-label={ariaLabel}
142
- style={style}
158
+ style={hasStyle ? style : undefined}
143
159
  className={cn(dynamicToggleVariants({ variant, size, shape }), slots?.root, className)}
144
160
  >
145
- {/* Filter morph: GooeyCanvas wraps backgrounds only */}
161
+ {/* Filter morph: GooeyCanvas with SVG rects (Safari-safe) */}
146
162
  {effectiveMorphMode === 'filter' && (
147
- <GooeyCanvas height={heightPx}>
148
- <div className="absolute inset-0 rounded-[inherit] bg-card" />
163
+ <>
164
+ <GooeyCanvas
165
+ height={heightPx}
166
+ width={SIZE_WIDTH_PX[size ?? 'default'] ?? 260}
167
+ radius={SHAPE_RADIUS[shape ?? 'pill'] ?? 9999}
168
+ expanded={groupActive}
169
+ />
170
+ {/* Group label text — outside the filtered canvas */}
149
171
  {groupLabelElement}
150
- </GooeyCanvas>
172
+ </>
151
173
  )}
152
174
 
153
175
  {/* Path morph: group label rendered directly (no gooey filter) */}
@@ -0,0 +1,413 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DynamicToggle — Sileo-style gooey (Safari-safe)</title>
7
+ <style>
8
+ :root {
9
+ --fg: #e2e8f0;
10
+ --bg: #0f172a;
11
+ --card: #1e293b;
12
+ --muted: #64748b;
13
+ --border: #334155;
14
+ --duration: 0.22;
15
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
16
+ --drop-off: 0.4;
17
+ }
18
+
19
+ * { box-sizing: border-box; margin: 0; }
20
+ body {
21
+ min-height: 100vh;
22
+ display: flex; flex-direction: column; align-items: center;
23
+ padding: 4rem 1rem; gap: 2rem;
24
+ background: var(--bg);
25
+ font-family: system-ui, -apple-system, sans-serif;
26
+ color: var(--fg);
27
+ }
28
+ h3 { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 2px; }
29
+ .row { display: flex; gap: 2rem; align-items: end; flex-wrap: wrap; justify-content: center; }
30
+ .sr-only {
31
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
32
+ overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
33
+ }
34
+
35
+ /* ================================================================
36
+ * CONTROL BASE
37
+ * ================================================================ */
38
+ .control {
39
+ --w: 260px;
40
+ --h: 38px;
41
+ --radius: 9999px;
42
+ --font: 0.75rem;
43
+ --bubble-h-pct: 40;
44
+ --bubble-inset-pct: 20;
45
+ --_r: min(var(--radius), calc(var(--h) / 2));
46
+
47
+ position: relative;
48
+ width: var(--w);
49
+ height: var(--h);
50
+ padding: 2px;
51
+ margin-top: calc(var(--h) * 0.5);
52
+ /* Sileo: GPU layer + containment on root */
53
+ transform: translateZ(0);
54
+ contain: layout style;
55
+ overflow: visible;
56
+ }
57
+
58
+ .control__track {
59
+ display: grid; place-items: center;
60
+ grid-template-columns: repeat(4, 1fr);
61
+ width: 100%; height: 100%;
62
+ position: relative;
63
+ z-index: 1;
64
+ }
65
+
66
+ .indicator {
67
+ position: absolute;
68
+ width: 50%; left: 0; top: 0; bottom: 0;
69
+ background: var(--fg);
70
+ border-radius: var(--radius);
71
+ transition: translate calc(var(--duration) * 1s) var(--ease);
72
+ }
73
+
74
+ label {
75
+ display: inline-grid; place-items: center;
76
+ height: 100%; width: 100%;
77
+ cursor: pointer; font-size: var(--font);
78
+ color: var(--fg); z-index: 2; font-weight: 500;
79
+ }
80
+ .control__track > label { color: var(--card); }
81
+
82
+ .group {
83
+ width: 100%; height: 100%;
84
+ display: grid; position: relative;
85
+ grid-template-columns: 1fr 1fr;
86
+ container-type: size;
87
+ overflow: hidden;
88
+ }
89
+ .group, .control__track > label:nth-of-type(1) { grid-column: span 2; }
90
+
91
+ .group .indicator {
92
+ background: var(--fg); left: 50%; top: 0;
93
+ translate: -50% 0;
94
+ pointer-events: none;
95
+ transition: translate calc(var(--duration) * 1s) var(--ease),
96
+ clip-path calc(var(--duration) * 1s) var(--ease),
97
+ background calc(var(--duration) * 1s) var(--ease);
98
+ clip-path: inset(73cqh calc(50% + 1px) calc(27cqh - 2px) calc(50% - 3px) round var(--radius));
99
+ }
100
+
101
+ /* ── Pseudo text ── */
102
+ .group::before {
103
+ content: attr(data-label);
104
+ position: absolute; left: 50%; top: 50%;
105
+ translate: -50% -80%;
106
+ color: var(--fg); font-size: var(--font); font-weight: 500;
107
+ z-index: 2; white-space: nowrap; pointer-events: none;
108
+ transition: scale calc(var(--duration) * 1s) var(--ease),
109
+ translate calc(var(--duration) * 1s) var(--ease),
110
+ opacity calc(var(--duration) * 1s) var(--ease);
111
+ }
112
+ .group::after {
113
+ content: attr(data-opts);
114
+ position: absolute; left: 50%; top: 50%;
115
+ translate: -50% 20%;
116
+ color: var(--muted); font-size: calc(var(--font) * 0.85);
117
+ opacity: 0.6; z-index: 2; white-space: nowrap; pointer-events: none;
118
+ transition: opacity calc(var(--duration) * 1s) var(--ease);
119
+ }
120
+ .group:not([data-opts])::after { content: none; }
121
+
122
+ .group label {
123
+ color: var(--muted); cursor: pointer; z-index: 2;
124
+ transition: color calc(var(--duration) * 1s) var(--ease),
125
+ opacity calc(var(--duration) * 1s) var(--ease),
126
+ translate calc(var(--duration) * 1s) var(--ease);
127
+ }
128
+ .group label span {
129
+ display: grid; place-items: center; height: 100%; width: 100%;
130
+ border-radius: var(--radius);
131
+ transition: scale calc(var(--duration) * 1s) var(--ease);
132
+ }
133
+
134
+ /* Collapsed modes */
135
+ .group[data-collapsed="title"]::before { translate: -50% -50%; }
136
+ .group[data-collapsed="title"]::after { display: none; }
137
+ .group[data-collapsed="title"]:not(:has(:checked)) label { opacity: 0; translate: 0 30%; }
138
+ .group[data-collapsed="title"]:not(:has(:checked)) label span { scale: 0.5; }
139
+
140
+ .group[data-collapsed="opts"]::before { display: none; }
141
+ .group[data-collapsed="opts"]::after { translate: -50% -50%; font-size: var(--font); opacity: 0.7; }
142
+ .group[data-collapsed="opts"]:not(:has(:checked)) label { opacity: 0; translate: 0 30%; }
143
+ .group[data-collapsed="opts"]:not(:has(:checked)) label span { scale: 0.5; }
144
+
145
+ /* Track states */
146
+ .control__track:has(> :checked) > label { color: var(--card); }
147
+ .control__track:not(:has(> :checked)) > label { color: var(--fg); opacity: var(--drop-off); }
148
+ .control__track:not(:has(> :checked)) > .indicator { translate: 100% 0; }
149
+ .control__track:has(> :checked) .group .indicator { background: transparent; }
150
+
151
+ /* Group expanded */
152
+ .control--goo .group:has(:checked)::before { opacity: 0; translate: -50% -80%; scale: 1; }
153
+ .group:has(:checked)::after { opacity: 0; }
154
+ .group:has(:checked) label { opacity: 0.75; color: var(--muted); translate: 0 0; }
155
+ .group:has(:checked) label span { scale: 1; }
156
+ .group:has(:checked) .indicator { background: var(--card); clip-path: inset(0 0 0 0 round var(--radius)); }
157
+ .group:has(:nth-of-type(1):checked) label:nth-of-type(1),
158
+ .group:has(:nth-of-type(2):checked) label:nth-of-type(2) { color: var(--fg); opacity: 1; }
159
+ .group:has(:nth-of-type(1):checked) .indicator { translate: -100% 0; }
160
+ .group:has(:nth-of-type(2):checked) .indicator { translate: 0 0; }
161
+
162
+ /* ================================================================
163
+ * SILEO-STYLE GOOEY: SVG filter on a canvas div, SVG rects inside
164
+ * Key: animated content is SVG <rect>, not HTML div.
165
+ * Safari re-renders SVG filter smoothly when SVG children change.
166
+ * ================================================================ */
167
+ .control--goo {
168
+ background: transparent;
169
+ border: none;
170
+ }
171
+
172
+ .goo-canvas {
173
+ position: absolute;
174
+ inset: 0;
175
+ pointer-events: none;
176
+ z-index: 0;
177
+ /* Sileo's Safari formula */
178
+ transform: translateZ(0);
179
+ contain: layout style;
180
+ overflow: visible;
181
+ }
182
+
183
+ .goo-svg {
184
+ position: absolute;
185
+ top: 0; left: 0;
186
+ overflow: visible;
187
+ pointer-events: none;
188
+ }
189
+
190
+ /* Bubble TEXT — outside the filtered SVG, on top */
191
+ .control--goo .bubble-label {
192
+ position: absolute;
193
+ z-index: 2;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ font-size: var(--font);
198
+ font-weight: 500;
199
+ color: var(--fg);
200
+ white-space: nowrap;
201
+ pointer-events: none;
202
+ opacity: 0;
203
+ transition: opacity calc(var(--duration) * 1s) var(--ease);
204
+ }
205
+ .control--goo:has(.group :checked) .bubble-label {
206
+ opacity: 1;
207
+ }
208
+
209
+ /* SIZES */
210
+ .control.sm { --w: 210px; --h: 30px; --font: 10px; }
211
+ .control.lg { --w: 320px; --h: 44px; --font: 14px; }
212
+ .control.xl { --w: 400px; --h: 52px; --font: 16px; }
213
+ .control.rounded { --radius: 12px; }
214
+ .control.square { --radius: 6px; }
215
+ </style>
216
+ </head>
217
+ <body>
218
+
219
+ <h3>Sileo-style gooey — SVG rects + filter (Safari-safe)</h3>
220
+ <p style="color:var(--muted);font-size:12px;max-width:600px;text-align:center">
221
+ SVG filter applied to a canvas div. Pill + bubble are SVG <code>&lt;rect&gt;</code> elements
222
+ animated via WAAPI. Safari renders filter smoothly because changes are SVG-internal.
223
+ </p>
224
+
225
+ <h3>collapsed="title" — Sizes</h3>
226
+ <div class="row">
227
+ <div><h3>SM</h3>
228
+ <div class="control sm control--goo" data-goo>
229
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
230
+ <div class="bubble-label">Changes</div>
231
+ <div class="control__track"><div class="indicator"></div>
232
+ <label for="gs-a">Tree</label><input class="sr-only" type="radio" name="gs" id="gs-a" checked>
233
+ <div class="group" data-collapsed="title" data-label="Changes"><div class="indicator"></div>
234
+ <label for="gs-b"><span>Flat</span></label><input class="sr-only" type="radio" name="gs" id="gs-b">
235
+ <label for="gs-c"><span>Grp</span></label><input class="sr-only" type="radio" name="gs" id="gs-c">
236
+ </div></div></div></div>
237
+
238
+ <div><h3>Default</h3>
239
+ <div class="control control--goo" data-goo>
240
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
241
+ <div class="bubble-label">Premium</div>
242
+ <div class="control__track"><div class="indicator"></div>
243
+ <label for="gd-a">Free</label><input class="sr-only" type="radio" name="gd" id="gd-a" checked>
244
+ <div class="group" data-collapsed="title" data-label="Premium"><div class="indicator"></div>
245
+ <label for="gd-b"><span>Solo</span></label><input class="sr-only" type="radio" name="gd" id="gd-b">
246
+ <label for="gd-c"><span>Team</span></label><input class="sr-only" type="radio" name="gd" id="gd-c">
247
+ </div></div></div></div>
248
+
249
+ <div><h3>LG</h3>
250
+ <div class="control lg control--goo" data-goo>
251
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
252
+ <div class="bubble-label">Billing</div>
253
+ <div class="control__track"><div class="indicator"></div>
254
+ <label for="gl-a">Annual</label><input class="sr-only" type="radio" name="gl" id="gl-a" checked>
255
+ <div class="group" data-collapsed="title" data-label="Billing"><div class="indicator"></div>
256
+ <label for="gl-b"><span>Monthly</span></label><input class="sr-only" type="radio" name="gl" id="gl-b">
257
+ <label for="gl-c"><span>Weekly</span></label><input class="sr-only" type="radio" name="gl" id="gl-c">
258
+ </div></div></div></div>
259
+ </div>
260
+
261
+ <h3>Shapes</h3>
262
+ <div class="row">
263
+ <div class="control rounded control--goo" data-goo>
264
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
265
+ <div class="bubble-label">Theme</div>
266
+ <div class="control__track"><div class="indicator"></div>
267
+ <label for="gr-a">System</label><input class="sr-only" type="radio" name="gr" id="gr-a">
268
+ <div class="group" data-collapsed="title" data-label="Theme"><div class="indicator"></div>
269
+ <label for="gr-b"><span>Light</span></label><input class="sr-only" type="radio" name="gr" id="gr-b" checked>
270
+ <label for="gr-c"><span>Dark</span></label><input class="sr-only" type="radio" name="gr" id="gr-c">
271
+ </div></div></div>
272
+
273
+ <div class="control square control--goo" data-goo>
274
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
275
+ <div class="bubble-label">Output</div>
276
+ <div class="control__track"><div class="indicator"></div>
277
+ <label for="gsq-a">Input</label><input class="sr-only" type="radio" name="gsq" id="gsq-a">
278
+ <div class="group" data-collapsed="title" data-label="Output"><div class="indicator"></div>
279
+ <label for="gsq-b"><span>JSON</span></label><input class="sr-only" type="radio" name="gsq" id="gsq-b" checked>
280
+ <label for="gsq-c"><span>XML</span></label><input class="sr-only" type="radio" name="gsq" id="gsq-c">
281
+ </div></div></div>
282
+ </div>
283
+
284
+ <h3>collapsed="opts"</h3>
285
+ <div class="row">
286
+ <div class="control control--goo" data-goo>
287
+ <div class="goo-canvas"><svg class="goo-svg"></svg></div>
288
+ <div class="bubble-label">Premium</div>
289
+ <div class="control__track"><div class="indicator"></div>
290
+ <label for="go-a">Free</label><input class="sr-only" type="radio" name="go" id="go-a" checked>
291
+ <div class="group" data-collapsed="opts" data-label="Premium" data-opts="Solo · Team"><div class="indicator"></div>
292
+ <label for="go-b"><span>Solo</span></label><input class="sr-only" type="radio" name="go" id="go-b">
293
+ <label for="go-c"><span>Team</span></label><input class="sr-only" type="radio" name="go" id="go-c">
294
+ </div></div></div>
295
+ </div>
296
+
297
+ <script>
298
+ // ================================================================
299
+ // SILEO-STYLE GOOEY ENGINE
300
+ // SVG filter + animated SVG rects (not HTML divs)
301
+ // ================================================================
302
+
303
+ const BLUR_RATIO = 0.15;
304
+ const DURATION = 0.25; // seconds
305
+ const OUTLINE_COLOR = '#334155';
306
+
307
+ let filterCounter = 0;
308
+
309
+ document.querySelectorAll('[data-goo]').forEach(control => {
310
+ const canvas = control.querySelector('.goo-canvas');
311
+ const svg = control.querySelector('.goo-svg');
312
+ const group = control.querySelector('.group');
313
+ const bubbleLabel = control.querySelector('.bubble-label');
314
+ const style = getComputedStyle(control);
315
+
316
+ const w = parseFloat(style.getPropertyValue('--w'));
317
+ const h = parseFloat(style.getPropertyValue('--h'));
318
+ const r = parseFloat(style.getPropertyValue('--radius'));
319
+ const effectiveR = Math.min(r, h / 2);
320
+ const bubbleHPct = parseFloat(style.getPropertyValue('--bubble-h-pct')) || 40;
321
+ const bubbleInsetPct = parseFloat(style.getPropertyValue('--bubble-inset-pct')) || 20;
322
+ const blur = Math.round(h * BLUR_RATIO);
323
+ const bubbleH = h * bubbleHPct / 100;
324
+ const bubbleInset = w * bubbleInsetPct / 100;
325
+ const bubbleW = w - 2 * bubbleInset;
326
+ const bubbleR = Math.min(effectiveR * 0.6, bubbleH * 0.45, 12);
327
+
328
+ // Create unique filter
329
+ const filterId = `goo-${filterCounter++}`;
330
+
331
+ // Build SVG content: filter defs + pill rect + bubble rect
332
+ const totalH = h + bubbleH;
333
+ svg.setAttribute('width', w);
334
+ svg.setAttribute('height', totalH);
335
+ svg.setAttribute('viewBox', `0 0 ${w} ${totalH}`);
336
+ svg.style.top = `-${bubbleH}px`;
337
+
338
+ svg.innerHTML = `
339
+ <defs>
340
+ <filter id="${filterId}" x="-20%" y="-20%" width="140%" height="140%"
341
+ color-interpolation-filters="sRGB">
342
+ <feGaussianBlur in="SourceGraphic" stdDeviation="${blur}" result="blur"/>
343
+ <feColorMatrix in="blur" mode="matrix"
344
+ values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10" result="goo"/>
345
+ <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
346
+ </filter>
347
+ </defs>
348
+ <rect class="pill-rect"
349
+ x="2" y="${bubbleH}" width="${w - 4}" height="${h - 4}" rx="${effectiveR}" ry="${effectiveR}"
350
+ fill="var(--card)"/>
351
+ <rect class="bubble-rect"
352
+ x="${bubbleInset}" y="${bubbleH}" width="${bubbleW}" height="0" rx="${bubbleR}" ry="${bubbleR}"
353
+ fill="var(--card)"/>
354
+ `;
355
+
356
+ // Apply filter + drop-shadow to canvas
357
+ canvas.style.filter = `url(#${filterId}) drop-shadow(0 0 0.5px ${OUTLINE_COLOR}) drop-shadow(0 0 0.5px ${OUTLINE_COLOR})`;
358
+
359
+ const pillRect = svg.querySelector('.pill-rect');
360
+ const bubbleRect = svg.querySelector('.bubble-rect');
361
+
362
+ // Position bubble label
363
+ bubbleLabel.style.left = bubbleInset + 'px';
364
+ bubbleLabel.style.right = bubbleInset + 'px';
365
+ bubbleLabel.style.height = bubbleH + 'px';
366
+ bubbleLabel.style.bottom = h + 'px';
367
+
368
+ // Animate bubble rect via WAAPI (like Sileo uses Motion)
369
+ let currentAnim = null;
370
+
371
+ function animateBubble(expand) {
372
+ if (currentAnim) currentAnim.cancel();
373
+
374
+ const targetY = expand ? bubbleH - bubbleH : bubbleH;
375
+ const targetH = expand ? bubbleH : 0;
376
+
377
+ currentAnim = bubbleRect.animate([
378
+ { // from current
379
+ y: bubbleRect.getAttribute('y'),
380
+ height: bubbleRect.getAttribute('height'),
381
+ },
382
+ { // to target
383
+ y: targetY,
384
+ height: targetH,
385
+ }
386
+ ], {
387
+ duration: DURATION * 1500,
388
+ easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)',
389
+ fill: 'forwards',
390
+ });
391
+
392
+ currentAnim.onfinish = () => {
393
+ bubbleRect.setAttribute('y', targetY);
394
+ bubbleRect.setAttribute('height', targetH);
395
+ };
396
+ }
397
+
398
+ // Listen for radio changes
399
+ function checkState() {
400
+ const expanded = group.querySelector(':checked') !== null;
401
+ animateBubble(expanded);
402
+ }
403
+
404
+ control.querySelectorAll('input[type="radio"]').forEach(input => {
405
+ input.addEventListener('change', checkState);
406
+ });
407
+
408
+ // Initial state
409
+ checkState();
410
+ });
411
+ </script>
412
+ </body>
413
+ </html>