Skip to content

feat: add blinds reveal to theme toggle - #134

Merged
starc007 merged 2 commits into
starc007:mainfrom
chakravartiavinit:feat/theme-toggle-blinds
Aug 1, 2026
Merged

feat: add blinds reveal to theme toggle#134
starc007 merged 2 commits into
starc007:mainfrom
chakravartiavinit:feat/theme-toggle-blinds

Conversation

@chakravartiavinit

Copy link
Copy Markdown
Contributor

Adds a fourth variant to the Theme Toggle page, alongside the existing rectangle and circle clip-path reveals.

Variant Mechanic
Blinds The new theme is masked in behind slats that widen shut across the viewport — a 72px repeating band whose opaque edge sweeps right, with a 20px feather so each slat opens as soft light rather than a hard wipe.

It installs under the existing @beui/theme-toggle slug; it's a prop on the same component, not a new file.

Why a mask rather than another clip-path

The three existing variants are all clip-path — one moving boundary, hard-edged, either linear (inset) or radial (circle). A fourth shape on that same mechanic would have read as a variation, not a different reveal. Slats need many simultaneous boundaries, and clip-path: polygon() can't describe disjoint regions, so this one is a mask.

That introduces the constraint the rest of the implementation is about: mask-image is not an animatable property. The slat edge is therefore an @property-registered <length> that the keyframes drive, and the mask expression re-resolves every frame the property ticks.

Why mask-size rather than a repeating gradient

The obvious form is repeating-linear-gradient, and the first version used one. It has a defect that only shows up at the end of the animation: the tile length of a repeating gradient is its last colour stop, so as soon as the soft edge passes 72px the tile grows with it. The opaque band can then never cover a full tile, and every slat keeps a feathered gap that never closes — a faint, permanent stripe of the old theme on the final frame before the pseudo-element is removed.

Anchoring the band to the far end of the tile and growing it backwards fixes the closing end but moves the artefact to the opening frame, and it caps the feather at a few px before that becomes visible as a veil at t=0.

A plain linear-gradient with mask-size: 72px 100% fixes the tile independently of the stops, so both ends land clean by construction — fully transparent at -20px, fully opaque at 72px — and the feather can be as wide as it wants. This is why the slats are vertical: seamless tiling under mask-size only holds at 90°, since a tilted gradient doesn't wrap at the tile boundaries. The 20px soft edge was worth more than the lean.

Fallback

--beui-vt-slat is unregistered on engines without @property, which makes var() invalid at computed-value time and drops mask-image to none — a fully opaque pseudo-element, so the theme swaps in one step. initial-value is 72px (closed) rather than 0px for the same reason: an open initial value would degrade to a blank page instead of an instant reveal. Reduced motion and browsers without the View Transition API take the existing setTheme path unchanged.

Slats sweep the whole viewport and have no origin point, so blinds ignores start; the other three variants are untouched.

Timing

700ms on cubic-bezier(0.16, 1, 0.3, 1), which is EASE_OUT from lib/ease.ts — matching the 700ms the circle variants already use. An earlier pass ran 640ms on a quint curve and read as a hard stop at the end.

Checks

bun run check (typecheck, lint, registry) passes — 55 registry components, no new warnings.

The reveal itself could not be watched end to end in an automated browser: Chrome skips view transitions on a document reporting visibilityState: "hidden", which is what a driven tab reports, and the existing circle-blur variant is equally inert there. So the mask was verified by pinning --beui-vt-slat to static values on a full-viewport probe and screenshotting each: -20px renders fully clear, 26px renders evenly feathered slats with no tiling seams, 72px renders fully opaque with no residual gaps. Those three are the states the correctness argument above rests on. Worth a manual look at the live transition before merge.

Adds a fourth `variant` to ThemeToggle alongside the rectangle and circle
clip-path reveals. The new theme is masked in behind slats that widen shut
across the viewport.

mask-image is not animatable, so the slat edge is an @property-registered
length that the keyframes drive; the mask re-resolves each frame it ticks.
mask-size fixes the tile at 72px instead of letting a repeating gradient's
last stop define it, which keeps the 20px soft edge from dragging the tile
wider than the slat and leaving a feathered gap that never closes.
@starc007

starc007 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Great work on the blinds reveal — the implementation is thoughtful and the fallback behavior is well documented.

One thing to address before merge: theme-toggle.tsx adds cubic-bezier(0.16, 1, 0.3, 1) inline, which is exactly the shared EASE_OUT_CSS token from lib/ease.ts. The repository conventions prohibit one-off curves, so please import and interpolate EASE_OUT_CSS into VT_CSS. That keeps future motion tuning centralized.

bun run check and all 134 tests pass locally.

@chakravartiavinit

Copy link
Copy Markdown
Contributor Author

Appreciate the review @starc007. I'll replace the inline curve with EASE_OUT_CSS and push the fix shortly.

The blinds keyframe hardcoded cubic-bezier(0.16, 1, 0.3, 1), which is
exactly EASE_OUT_CSS from lib/ease.ts. Interpolate the token so motion
tuning stays centralized. The circle variants keep their Material curve,
which has no token yet.
@chakravartiavinit

Copy link
Copy Markdown
Contributor Author

Added the fix for the above

@starc007
starc007 merged commit 3292697 into starc007:main Aug 1, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants