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 6 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.
Interaction Patterns That Improve CVR: 6 Ways to Extend Attention
A practical map of six interaction patterns from the sakimyto.com CVR interaction directory: invite touch, build anticipation, start chain reactions, make controls malleable, vary rewards, and remove endpoints.
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.
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.