Interactive UI with Pure CSS — Practical Use of transform, transition, and Custom Properties
Go deeper on this topic
Canvas vs DOM & Interactive UI
Canvas vs DOM tradeoffs, CSS interactions, custom physics, and portfolio design
Article 3 of 5 in this series.
Next reads
The Limits of a 180-Line Custom Physics Engine — Why I Dropped Matter.js and What I Couldn't Cover
Built a custom 2D physics engine in 166 lines of physics.ts, reducing Matter.js's 300KB to 4KB. The trade-offs of focusing on gravity, collision, and bounce while giving up constraints, friction, and stacking.
Building a Custom Physics Engine for the Browser from Scratch
How I built a lightweight 180-line physics engine in TypeScript instead of using Matter.js for my portfolio site. Reduced bundle size from 300KB to 4KB while maintaining 60fps.
Canvas vs DOM — Decision Criteria from 203 Components with Real Benchmark Data
The decision criteria used to assign 203 interactive components between Canvas API and DOM, explained with real examples and measured performance data.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Canvas API vs. DOM Decision Criteria
On sakimytocom, rendering is split into two approaches:
| Condition | Choice | Reason |
|---|---|---|
| 100+ elements | Canvas | DOM reflows are expensive |
| Text/focus needed | DOM | Accessibility |
| Primarily rotation/scaling | DOM + CSS Transform | GPU compositing |
| Pixel-level drawing | Canvas | Flexibility required |
FidgetSpinner, InfiniteKnob, and ToggleSwitch all use DOM + CSS Transform.
Physical Rotation with CSS Transform
FidgetSpinner rotates by flicking with a finger. The rotation angle is computed in JS and applied via CSS transform: rotate():
const [angle, setAngle] = useState(0)
const velocityRef = useRef(0)
// Compute angular velocity from drag interaction
function onPointerMove(e: PointerEvent) {
const dx = e.clientX - centerX
const dy = e.clientY - centerY
const currentAngle = Math.atan2(dy, dx)
const delta = currentAngle - lastAngle.current
velocityRef.current = delta * 60 // rad/frame → rad/s
}.spinner {
transform: rotate(var(--angle));
will-change: transform;
}will-change: transform promotes the element to a compositing layer. This offloads rotation to the GPU without blocking the main thread.
Real-Time Control via CSS Custom Properties
Using CSS Custom Properties is more flexible than writing style.transform directly from JS:
element.style.setProperty('--angle', `\${angle}deg`)
element.style.setProperty('--scale', String(scale)).knob {
transform: rotate(var(--angle)) scale(var(--scale));
transition: transform 0.05s ease-out;
}Three advantages:
- CSS media queries can override the variables
- Natural composition with
transition - Real-time debugging in DevTools
Infinite Rotation with InfiniteKnob
InfiniteKnob rotates continuously beyond 360 degrees. While CSS rotate() only has visual meaning within the 0-360 range, the internal angle value is accumulated:
const totalAngle = useRef(0)
function onRotate(delta: number) {
totalAngle.current += delta
// Normalize display angle to 0-360
const displayAngle = ((totalAngle.current % 360) + 360) % 360
element.style.setProperty('--angle', `\${displayAngle}deg`)
// Compute output value from cumulative angle
const value = totalAngle.current / 360
onChange(value)
}((x % 360) + 360) % 360 is a normalization pattern that handles negative angles correctly.
Decay Animation with CSS Transition
The inertial rotation of FidgetSpinner uses requestAnimationFrame for decay calculation:
function decayLoop() {
if (Math.abs(velocityRef.current) < 0.01) return
velocityRef.current *= 0.98 // friction
setAngle(prev => prev + velocityRef.current)
requestAnimationFrame(decayLoop)
}CSS Transition alone cannot express exponential decay. transition excels at linear interpolation from A to B, so physical decay is handled in JS while CSS handles only rendering.
Accessibility of ToggleSwitch
The toggle switch is implemented with <button role="switch">:
<button
role="switch"
aria-checked={isOn}
onClick={() => setIsOn(!isOn)}
className="toggle-track"
>
<span className="toggle-thumb" />
</button>.toggle-track {
width: 48px;
height: 24px;
border-radius: 12px;
background: var(--track-color);
transition: background 0.2s;
}
.toggle-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
transform: translateX(var(--thumb-x));
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}role="switch" and aria-checked allow screen readers to announce "on/off." Visual animation and accessibility are not mutually exclusive.
Integration with Tailwind CSS v4
In Tailwind v4, dark mode switching is defined with @custom-variant:
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));Interactive components need to maintain visibility in both dark and light modes. Defining theme colors with CSS Custom Properties eliminates flicker during theme switching.
Summary: Tools for CSS Interactive UI
For a systematic approach to CSS design patterns, the books on the tool shelf are excellent references. The combination of grid and custom properties, in particular, greatly enhances layout flexibility.