Skip to content

Commit 0c3ef0e

Browse files
committed
fix(checkbox): ensure correct rendering on iOS & macOS 26
Safari (macOS & iOS, tested on Safari 26) & probably even earlier versions have a rendering bug where transitions on descendants whose end state is triggered ONLY via a parent selector using `:has()` may not animate. Instead, Safari sometimes jumps directly to the final state (or never paints the transition) until a subsequent layout invalidation (e.g. tab switch, resize) happens. Now we are using an equivalent selector that does NOT rely on `:has()`, using the adjacency between the input and the visual box. This ensures the transitions for the check mark & background color run reliably in Safari while keeping the simpler `:has()` version commented for future re-implementation or cleanup.
1 parent df3c380 commit 0c3ef0e

File tree

2 files changed

+77
-13
lines changed

2 files changed

+77
-13
lines changed

src/components/checkbox/checkbox.scss

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,41 @@
7979
}
8080
}
8181

82-
&:not(.indeterminate):has(input[type='checkbox']:checked) {
82+
/*
83+
* Safari (macOS & iOS, tested on Safari 26) & probably even earlier versions have
84+
* a rendering bug where transitions on descendants whose end state is triggered
85+
* ONLY via a parent selector using `:has()` may not animate. Instead, Safari
86+
* sometimes jumps directly to the final state (or never paints the transition)
87+
* until a subsequent layout invalidation (e.g. tab switch, resize) happens.
88+
*
89+
* Workaround: provide an equivalent selector that does NOT rely on `:has()`,
90+
* using the adjacency between the input and the visual box. This ensures the
91+
* `stroke-dashoffset` transition for the check mark runs reliably in Safari
92+
* while keeping the simpler `:has()` version commented for future re-implementation
93+
* or cleanup.
94+
*
95+
* &:not(.indeterminate):has(input[type='checkbox']:checked) {
96+
* svg.check-mark {
97+
* opacity: 1;
98+
* path {
99+
* stroke-dashoffset: 0;
100+
* }
101+
* }
102+
* }
103+
* Using the `:has()` selector is more reliable, because it doesn't
104+
* depend on the DOM structure (e.g. if the markup changes and the input is
105+
* no longer adjacent to the box), but Safari support for `:has()` is still
106+
* somewhat inconsistent.
107+
*/
108+
109+
&:not(.indeterminate)
110+
> input[type='checkbox']:checked
111+
+ .box
83112
svg.check-mark {
84-
opacity: 1;
113+
opacity: 1;
85114

86-
path {
87-
stroke-dashoffset: 0;
88-
}
115+
path {
116+
stroke-dashoffset: 0;
89117
}
90118
}
91119
}

src/style/internal/boolean-input.scss

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,31 @@ label.boolean-input-label {
110110
rgb(var(--contrast-300))
111111
);
112112

113+
/*
114+
* NOTE: Original selectors using `:has()` are commented out due to Safari
115+
* rendering bugs where descendant transitions (e.g. SVG stroke animations)
116+
* or box/background updates sometimes fail to animate or even repaint
117+
* reliably when the state change is detected only via `:has()`.
118+
*
119+
* Original (kept for future re-implementation, or cleanup):
120+
* .boolean-input:has(input[type='checkbox']:checked) &,
121+
* .boolean-input:has(input[type='radio']:checked) & {
122+
* ...
123+
* }
124+
*
125+
* Replacement uses adjacency: the markup places the `<input>` immediately
126+
* before .box, so we can select the checked state with
127+
* input:checked + .box. We retain the explicit `.checked` class pathway in
128+
* case some templates toggle that class manually.
129+
*
130+
* Using the `:has()` selector is more reliable, because it doesn't
131+
* depend on the DOM structure (e.g. if the markup changes and the input is
132+
* no longer adjacent to the box), but Safari support for `:has()` is still
133+
* somewhat inconsistent.
134+
*/
113135
.checked &,
114-
.boolean-input:has(input[type='checkbox']:checked) &,
115-
.boolean-input:has(input[type='radio']:checked) & {
136+
.boolean-input > input[type='checkbox']:checked + &,
137+
.boolean-input > input[type='radio']:checked + & {
116138
background-color: var(
117139
--lime-primary-color,
118140
var(--limel-theme-primary-color)
@@ -127,12 +149,19 @@ label.boolean-input-label {
127149
opacity: 0.4;
128150
}
129151

130-
.boolean-input:not(.disabled):has(label.boolean-input-label:hover) & {
152+
/*
153+
* See previous comment about Safari rendering bugs ☝️.
154+
*
155+
* Original (kept for for future re-implementation, or cleanup):
156+
* .boolean-input:not(.disabled):has(label.boolean-input-label:hover) & { ... }
157+
* .boolean-input:not(.disabled):has(label.boolean-input-label:active) & { ... }
158+
*/
159+
.boolean-input:not(.disabled):hover & {
131160
will-change: box-shadow;
132161
box-shadow: var(--button-shadow-hovered);
133162
}
134163

135-
.boolean-input:not(.disabled):has(label.boolean-input-label:active) & {
164+
.boolean-input:not(.disabled):active & {
136165
will-change: box-shadow;
137166
box-shadow: var(--button-shadow-pressed);
138167
}
@@ -145,10 +174,16 @@ label.boolean-input-label {
145174
inset: -0.1875rem; // 3px
146175
border-radius: inherit;
147176

148-
.boolean-input:has(input[type='checkbox']:focus-visible) &,
149-
.boolean-input:has(input[type='radio']:focus-visible) & {
177+
/*
178+
* See previous comment about Safari rendering bugs ☝️.
179+
*
180+
* Original (kept for for future re-implementation, or cleanup):
181+
* .boolean-input:has(input[type='checkbox']:focus-visible) &,
182+
* .boolean-input:has(input[type='radio']:focus-visible) & { ...}
183+
*/
184+
.boolean-input > input[type='checkbox']:focus-visible + &,
185+
.boolean-input > input[type='radio']:focus-visible + & {
150186
will-change: box-shadow;
151-
152187
box-shadow: var(--shadow-depth-8-focused);
153188
}
154189
}
@@ -171,7 +206,8 @@ label.boolean-input-label {
171206

172207
background-color: rgb(var(--color-white));
173208

174-
.boolean-input:not(.disabled):has(label.boolean-input-label:hover) & {
209+
/* Hover fallback for pseudo-element (see explanation above). */
210+
.boolean-input:not(.disabled):hover & {
175211
will-change: opacity, box-shadow, transform, width;
176212
}
177213
}

0 commit comments

Comments
 (0)