Revisiting stroboscopic animation with JavaScript and SVG
| Updated | 7 September 2026 |
| Published | 6 January 2023 |
In 2023, while building the turntable interface for Crate Guide, I discovered that rotating a ring of SVG dots could produce a stroboscopic effect on my monitor. With the right spacing, the dots appeared to stand still even though the platter was turning.
I thought it was pretty cool that the display could do that. It was also a fairly specific trick: I tuned it around a 60 Hz display, and changing the turntable to 45 RPM broke the illusion.
I no longer have that monitor. That seemed like a good reason to have another go.
The new version takes a different approach. The first version used the monitor to create the illusion. This one calculates the motion that would be visible under a strobe, then draws it. Only the part of the platter reached by the red light shows the effect; the rest becomes a blurred metallic ring.
Try the current turntable in Crate Guide. Start a deck, move the pitch control, switch between 33 and 45 RPM, then stop it. Watch the lower-left edge of the platter. No account is required. If the platter is hidden, enable Turntable in the header.
This is an updated account of the implementation. I’ve kept the original 2023 experiment, including its video and dash-spacing problem, at the bottom.
What the dots tell you
The reference is the Technics SL-1200 family. Its platter markings make speed visible: under the strobe, one row appears stationary when successive flashes catch an equivalent part of the repeating pattern. A small speed difference makes that row appear to drift instead.
The legend reproduced in Crate Guide gives four approximate pitch offsets: +6.4%, +3.3%, 0%, and −3.3%. Those are also the values in the SL-1200GR operating instructions, page 24. These aren’t four independent animations on the physical turntable. They are differently spaced markings on the same rotating platter.
That distinction matters. If I simply rotated the entire SVG more slowly until the dots looked right, the record label would rotate at the wrong speed too. The record needs to follow the physical motion; the lit dots need to follow the apparent motion.
One platter, two kinds of motion
For the record, convert RPM to degrees per millisecond, apply the pitch offset, and integrate the angle using the elapsed time between frames.
The control labelled 33 means 33⅓ RPM, not exactly 33. That small difference is worth getting right when the point of the animation is showing small speed differences.
For the strobe, start with a repeating pattern. In this implementation, the nominal-speed row has 180 dots, so each dot is two degrees from the next.
At 33⅓ RPM, the platter turns through 200 degrees per second. Sample it 100 times per second and it moves two degrees between samples: exactly one dot spacing. The next dot replaces the previous one, and the pattern looks stationary.
At +1% pitch, it moves 2.02 degrees between samples. The whole two-degree step is visually indistinguishable from no movement. The remaining 0.02 degrees produces a slow drift. At −1%, the remainder is negative and the pattern drifts the other way.
This is temporal aliasing, closely related to the wagon-wheel effect. We can’t tell which identical dot we’re looking at, so a fast-moving repeating pattern can look like a slowly moving one.
Drawing the remainder
The core calculation is small. This excerpt isolates the calculation from its surrounding function and guards:
// physicalVelocity: degrees per millisecond// flashFrequency: simulated flashes per secondconst mirrorSpacing = 360 / dotCountconst oneMirrorStepVelocity = (mirrorSpacing * flashFrequency) / 1000const wholeStepsPerFlash = Math.round(physicalVelocity / oneMirrorStepVelocity)
const apparentVelocity = physicalVelocity - wholeStepsPerFlash * oneMirrorStepVelocityMath.round selects the nearest whole number of dot spacings travelled per simulated flash. Subtracting that motion leaves the apparent velocity. Each row gets its own result because each has a different dot spacing.
There is no 100 Hz red on/off animation here. The light remains visually steady, and requestAnimationFrame draws the slowly moving pattern that the virtual flashes would reveal.
The sampling frequency comes from the selected nominal speed, not the current pitch-adjusted speed. Written without the surrounding helpers:
const referenceDotCount = 180const nominalRpm = rpm === 33 ? 100 / 3 : rpmconst flashFrequency = (referenceDotCount * nominalRpm) / 60That gives this model a 100 Hz reference at 33⅓ RPM and a 135 Hz reference at 45 RPM. Changing pitch changes the physical velocity but leaves that reference alone. If the reference followed the pitch control too, the nominal row would keep looking stationary and tell us nothing.
The browser’s refresh rate still determines how smoothly it can display the result. It no longer determines which pitch makes a row lock. Missed frames, pixel resolution, and display characteristics haven’t stopped existing; they just aren’t inputs to the intended strobe behaviour.
Choosing the rows
The model uses these integer dot counts, listed from the outside edge towards the record:
| Dots | Model lock pitch |
|---|---|
| 186 | −3.23% |
| 180 | 0% |
| 174 | +3.45% |
| 169 | +6.51% |
The lock pitch is (180 / dotCount - 1) * 100. These values are close to the printed legend, but not identical to it.
These are parameters of the emulation, not a claim that I’ve measured the exact markings or electronics of a particular SL-1200. The MK2 service manual describes a quartz-referenced strobe and shows a speed-selected divider. The same manual’s older legend prints +6% rather than the GR’s +6.4%, so even the documentation needs a model attached to it.
The original post’s blanket description of a 50 Hz lamp was too simplistic. This version chooses its own reference cadence explicitly; it doesn’t assume that every Technics model has the same lamp circuit or markings.
The light doesn’t rotate
Getting the apparent speed right was only part of getting it to look right.
The lamp is represented as coming from the platter-facing side of the raised power-switch housing. From our top-down view, the bulb itself is hidden. What we see is the dark circular cap, its steel rim, and the light landing on nearby surfaces.
The beam is a softened 90-degree sector pointing towards the platter. It catches the circular markings, washes over the record, and picks out a few grooves. Its width, falloff, and brightness are visual choices rather than measured optics.
The SVG separates that fixed lighting from the moving geometry:
- The record and its label rotate at the physical platter speed.
- The unlit physical dots rotate with the platter, fading out as it speeds up.
- Blurred concentric bands provide the appearance of the unlit rim at speed.
- Four independently phased dot rows are visible through the stationary light mask.
- The groove highlights and the light mask remain attached to the deck, not the record.
Putting the mask outside the rotating group is the important part. This abbreviated structure shows the relationship; the mask definitions and other layers are omitted:
<!-- Lamp stays fixed; the pattern moves through its light. --><g mask="url(#strobe-mask)"> <g transform="rotate(...)"> <use href="#mirrors-180" /> </g></g>If the mask were inside the rotating group, the illuminated patch would travel around the platter. It’s a small nesting decision with a large visual consequence.
The production component also gives every SVG definition a deck-specific ID. Two platters on the same page shouldn’t accidentally refer to each other’s masks or gradients.
Circles, not short dashes
The original used rounded, zero-length SVG dashes. This version creates explicit circles at evenly spaced angular positions and reuses the resulting geometry with <use>.
The placement calculation is just:
const angle = (index * 2 * Math.PI) / dotCountconst x = radius * Math.cos(angle)const y = radius * Math.sin(angle)This guarantees an integer number of evenly spaced dots around the ring. Dot diameter is independent of the spacing, and changing it doesn’t turn the dots into little capsules. The physical and illuminated layers reuse the same circle definitions, so they can’t disagree about the shape of a dot.
Stopping was harder than spinning
An early version of the new animation looked convincing at speed but wrong at rest. In the red area, both the stationary physical dots and the simulated dots were visible. Their phases had evolved independently, so stopping revealed two competing patterns.
There were two parts to the fix.
First, the lit and unlit representations use complementary masks. The core of the beam belongs to the simulated dots; the area outside it belongs to the physical dots. The soft boundary blends between them rather than putting two fully visible patterns on top of each other.
Second, as the platter slows, the illuminated pattern converges onto the physical pattern. At rest, every row uses the physical platter angle exactly.
The subtle part is deciding how far a row needs to move to line up. A ring of 180 identical dots repeats every two degrees. A simulated phase of 359 degrees and a physical phase of zero don’t require an almost complete revolution to reconcile.
Instead, find the shortest equivalent phase difference within one dot spacing. The maths below is an explanatory version of the low-speed phase blend:
const period = 360 / dotCountconst difference = simulatedAngle - physicalAngleconst shortestDifference = ((((difference + period / 2) % period) + period) % period) - period / 2
// motionMix is 0 at rest and reaches 1 above the low-speed blend region.const blend = motionMix * motionMix * (3 - 2 * motionMix)const renderAngle = physicalAngle + shortestDifference * blendThe double modulo handles JavaScript’s negative remainders. The blend expression is smoothstep: it eases the handover at both ends. At normal speed, the actual implementation uses the independently simulated angle directly; at zero speed, it returns the physical angle directly. It also wraps the rendered angle to a single revolution.
This is a presentation correction, not a claim about how a real strobe behaves during braking. The goal is to let a useful high-speed illusion settle back into one coherent object.
What is simulated, and what is just drawn?
The current implementation has a physical rotation model and an apparent-motion model. It doesn’t have a physical light transport model, a shutter model, or an accurate simulation of the turntable’s motor.
Acceleration and braking use a time-scaled smoothing step. This approximates the same response across frame intervals; it isn’t an exact frame-rate-invariant motor model. The animation loop continues after pressing stop until the platter has slowed below a small threshold.
Elapsed time is capped after a long gap, so returning to a backgrounded tab doesn’t produce a huge jump. That deliberately favours a stable presentation over accounting for every missed revolution.
The blurred rim isn’t rendered by accumulating motion samples. It is a set of static, softened metallic bands revealed as the physical dots fade away. The light on the grooves is similarly drawn rather than calculated from the material’s reflectance.
For now, visual correctness has been the priority. Shared SVG definitions and updating a small set of group transforms keep the structure manageable, but they don’t prove that SVG filters are cheap. I haven’t carried forward the old CPU measurements as benchmarks for this version. Performance needs its own measurements on the hardware the app is meant to run on.
The animation maths lives in pure functions, separately from the Vue component. That makes it possible to test row locking, forward and reverse apparent motion, angle wrapping, and the handover at rest without relying on screenshots. Browser testing is still necessary for the part the equations can’t establish: whether the whole thing looks convincing.
Source and next steps
The implementation is in the public Crate Guide repository. These links point to the deployed version described here, so the underlying implementation remains traceable as the app changes:
- Platter rendering and SVG layers
- Physical and apparent-motion calculations
- Tests for the animation maths
The excerpts above focus on individual ideas rather than reproduce the entire component. A useful next experiment would be an inspection mode that lets you turn the masks and blur off, and compare physical motion with apparent motion directly.
I’m much happier with how this version looks. The biggest improvement came from separating things that the original treated as one: the motion of the record, the motion we perceive in the dots, and the light that lets us see them.
The original experiment, 2023
This section preserves the idea and a few details from the first version, rather than the full original tutorial. The video below is the old implementation, not the current one. A recording also has its own sampling and playback timing, so it isn’t a reliable test of how the original behaved on a particular display.
The first experiment rotated the entire platter SVG. Its rows were circles with stroke-dasharray, using rounded caps to make zero-length dashes appear as dots:
<circle r="174" stroke="#d7d8dd" stroke-width="2.2" stroke-linecap="round" stroke-dasharray="0,3.33333"/>The rotation itself used the time since the previous animation frame. The essential step was angle += elapsedTime / fullRotationDuration * 360, followed by updating the SVG’s rotation and requesting another frame.
I adjusted the dash spacing by eye until the appropriate rows appeared stationary at their intended pitch offsets. The display was effectively doing the sampling, rather than JavaScript calculating a separate apparent velocity.
That produced an awkward geometric problem: spacing that looked right in motion didn’t necessarily fit an integer number of dashes around the circumference. The join could leave a visible seam.
Small spacing adjustments improved the join, although they also affected the timing that made the strobe illusion work.
The original post targeted 60 Hz and 120 Hz displays. That was a limitation of the approach, not a general guarantee across displays or frame timings. Switching to 45 RPM broke the tuning, and the effect covered the entire platter ring rather than just the area reached by the lamp.
I later added separate speed-up and slow-down loops. They changed the rotation duration by a fixed amount per frame, which made the transition depend on frame timing. The current implementation keeps acceleration, steady motion, and braking in one loop instead.
Two items from the original list of future enhancements were to restrict the strobe to the red-lit area near the start/stop button and to blur the rest of the rim. Those turned out to be good directions. I just needed to stop asking the monitor to do all the work.