perf(motion): convert layout-property transitions to transforms

Audit P1: 5 of 7 layout-property animations were transition-[width]
or transition-[left] — these trigger reflow + repaint every frame.
Convert to transform-based equivalents that run on the compositor.

Subscription.tsx — 3 progress bars + 1 toggle:
  • Device usage bar (track w-16) — width % → scaleX with a 0.0625
    floor (= 4px on 64px track) preserving the minWidth: 4px guard
    when connectedDevices > 0.
  • Traffic-purchase progress — absolute inset-0 + width % →
    transform: scaleX with origin-left, gradient stretches with the
    fill same as before.
  • Subscription period progress — same conversion.
  • Autopay toggle thumb — left: 3↔26px → translateX(0↔23px) with
    fixed left: 3px. role='switch' + aria-checked unchanged.

globals.css — .progress-fill utility class:
  • transition-property: width, background-color → transform,
    background-color. Width set to w-full + origin-left so consumers
    just set transform: scaleX(0..1). Currently has no .tsx consumers
    (grep clean) but the API stays valid for future use.

Skipped (bounded one-shot, transform alternative has tradeoffs):
  • Info.tsx + InfoPageView.tsx accordion height. Animating from 0
    to content-driven scrollHeight needs layout one way or another —
    grid-template-rows 0fr/1fr and max-height also layout-class
    properties; transform: scaleY squishes text contents. Cost is
    bounded: one animation per user click, not continuous, not on
    critical path. Separate work if priority changes.

Visual parity: scaleX renders identical geometry to width % on the
inner fill. box-shadow on the device bar (0 0 8px 40)
scales proportionally — tiny visual difference at full width, none
visible at partial widths.
This commit is contained in:
c0mrade
2026-05-26 20:27:47 +03:00
parent cef675825b
commit eb13689ae7
2 changed files with 23 additions and 11 deletions

View File

@@ -997,13 +997,18 @@ export default function Subscription() {
className="h-[6px] w-full overflow-hidden rounded-full"
style={{ background: g.textGhost }}
>
{/* scaleX (compositor) instead of width (layout-thrash).
Track is 64px (w-16), so 0.0625 floor = 4px minimum,
preserving the prior minWidth behaviour. */}
<div
className="h-full rounded-full transition-[width] duration-500"
className="h-full w-full origin-left rounded-full transition-transform duration-500"
style={{
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
transform: `scaleX(${(() => {
const pct = connectedDevices / subscription.device_limit;
return connectedDevices > 0 ? Math.max(pct, 0.0625) : 0;
})()})`,
background: zone.mainHex,
boxShadow: `0 0 8px ${zone.mainHex}40`,
minWidth: connectedDevices > 0 ? '4px' : '0px',
}}
/>
</div>
@@ -1146,9 +1151,9 @@ export default function Subscription() {
style={{ background: g.trackBg }}
>
<div
className="absolute inset-0 rounded-full transition-[width] duration-500"
className="absolute inset-0 origin-left rounded-full transition-transform duration-500"
style={{
width: `${purchase.progress_percent}%`,
transform: `scaleX(${purchase.progress_percent / 100})`,
background: `linear-gradient(90deg, ${zone.mainHex}, ${zone.mainHex}80)`,
}}
/>
@@ -1193,10 +1198,15 @@ export default function Subscription() {
background: subscription.autopay_enabled ? zone.mainHex : g.textGhost,
}}
>
{/* translateX (compositor) instead of left (layout-thrash).
Resting position pinned at left:3px; on toggles a 23px
slide on the GPU. */}
<span
className="absolute top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-[left] duration-300"
className="absolute left-[3px] top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-transform duration-300"
style={{
left: subscription.autopay_enabled ? '26px' : '3px',
transform: subscription.autopay_enabled
? 'translateX(23px)'
: 'translateX(0)',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
/>
@@ -1387,9 +1397,9 @@ export default function Subscription() {
style={{ background: g.trackBg }}
>
<div
className="absolute inset-0 rounded-full transition-[width] duration-500"
className="absolute inset-0 origin-left rounded-full transition-transform duration-500"
style={{
width: `${progress}%`,
transform: `scaleX(${progress / 100})`,
background:
'linear-gradient(90deg, rgb(var(--color-accent-500)), rgb(var(--color-accent-400)))',
}}

View File

@@ -630,8 +630,10 @@ img.twemoji {
}
.progress-fill {
@apply h-full rounded-full duration-500 ease-smooth;
transition-property: width, background-color;
/* GPU-composited progress fill — consumer sets transform: scaleX(0..1)
with transform-origin: left. Avoids width-triggered reflow per frame. */
@apply h-full w-full origin-left rounded-full duration-500 ease-smooth;
transition-property: transform, background-color;
}
/* Stat card - Dark */