Skip to content

Stroboscopic animation with JavaScript and SVG

Updated 8 January 2023
Published 6 January 2023

Introduction

One of my intentions in the development of Crate Guide was to build an interface that resembled closely that of a Technics SL-1200 turntable, the most common turntable used by DJs. The idea was that the interface would be intuitive, requiring less learning and cognitive overhead. The web app is intended to be used on a laptop or desktop and only occasionally glanced at and interacted with during a performance.

Most of the interface proved fairly simple to represent in the browser, with the exception of one important feature: the stroboscopic dots that circumscribe the platter. They provide an accurate visual reference for the pitch adjustment at a glace. The dots are lit by a red strobe light flashing at 50Hz. When the platter spins at +6.4%, +3.3%, 0% and -3.3% one of the four dots will be stationary.

I had several ideas on how to implement this, most of these involved having separate rows of dots rotating at a calculated rate according to the pitch adjustment. It wasn’t until I created the dots in the SVG and started playing with a simple spin animation that I realised it was possible to implement a stroboscopic effect with a 60Hz/120Hz display. This method does have the downside of requiring separate implementations to work on displays with refresh rates not a multiple 60Hz, but is far more interesting, so I pursued it. In the future I’ll probably provide implementations for other refresh rates, but I currently do not have the facilities to test them.

A demo of this solution can be viewed in the app without an account and the complete Single File Component is hosted on GitHub.

The SVG

The SVG is placed within a wrapper in a Vue template. I won’t cover everything that’s going on here. It is a fairly simple SVG, but I will point out that the dots are created using the stroke-dasharray attribute. The dashes appear as dots by applying stroke-linecap: round; in the style and setting the first argument of stroke-dasharray to 0. The second argument is the distance between the dashes. This is what I adjusted to get the timing of the stroboscopic effect to function like that of the Technics turntable. More on this later.

<template>
<div class="record-platter-wrapper">
<svg
width="366"
height="366"
viewBox="0 0 366 366"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="record-platter"
ref="platter"
>
<!-- the platter ring -->
<g>
<circle r="183" fill="#000" />
<circle r="181" fill="#d7d8dd" />
<circle r="180" fill="#222" />
<circle class="dots" r="177.7" stroke-dasharray="0,3.303" />
<circle class="dots large" r="174" stroke-dasharray="0,3.33333" />
<circle class="dots" r="170" stroke-dasharray="0,3.3702" />
<circle class="dots" r="167" stroke-dasharray="0,3.418" />
<circle r="165" fill="#d7d8dd" />
</g>
<!-- the record -->
<g v-if="session.decks[deckID].loadedTrack?.recordID">
<clipPath id="coverClip">
<circle r="51" />
</clipPath>
<circle r="164" fill="#000" />
<circle r="162.2" fill="#151515" />
<circle r="142" fill="#000" />
<circle r="141" fill="#151515" />
<circle r="122" fill="#000" />
<circle r="121" fill="#151515" />
<circle r="100" fill="#000" />
<circle r="99" fill="#151515" />
<circle r="66" fill="#030303" />
<image
:href="coverImg"
height="104"
width="104"
clip-path="url(#coverClip)"
x="-52"
y="-52"
/>
</g>
<!-- the slipmat -->
<g class="slipmat" v-else>
<circle r="164" fill="#222" />
<circle r="146" stroke-width="10" stroke="#8c4394" fill="transparent" />
<text
y="-23"
fill="#8c4394"
:transform="deckID === 1 ? 'rotate(180)' : ''"
>
Crate
</text>
<text
y="-23"
fill="#b9adda"
:transform="deckID === 0 ? 'rotate(180)' : ''"
>
Guide
</text>
<circle r="2" fill="#d7d8dd" />
</g>
</svg>
</div>
</template>

It is accompanied by the following style in a Single File Component:

<style scoped lang="scss">
.record-platter-wrapper {
width: 660px;
height: 100%;
position: absolute;
left: 2%;
z-index: 1;
}
.record-platter {
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
g {
transform: translate(183px, 183px);
}
.dots {
stroke: #d7d8dd;
stroke-width: 1.6;
stroke-linecap: round;
&.large {
stroke-width: 2.2;
}
}
.slipmat {
text {
font-size: 5rem;
font-weight: 600;
letter-spacing: 0.1rem;
text-anchor: middle;
font-family: 'Sonsie One', serif;
user-select: none;
}
}
}
</style>

JavaScript and requestAnimationFrame()

Since this is a Vue component, I have utilised ref, computed and watch. Here’s an explanation of what’s going on here:

  • ref is referencing the SVG element.
  • fullRotationDuration is a computed property that calculates the duration of a full rotation of the animation, based on the faderPosition, the turntablePitchRange in the settings and the rpm setting.
  • updateAnimation() is a fairly typical implementation of requestAnimationFrame(), in that a calculation is made, a transform is applied to an element and the function is recursively called with requestAnimationFrame(). This is telling the browser to run the function again before the next repaint.
  • In this implementation, angle is set so that the time since last update ÷ full rotation duration × 360 is added to the current angle of the element.
  • Starting the animation, which is controlled by the start • stop button is achieved by using a watch function. The watch function watches for a change in state and begins the animation if playing is true.
  • Ending the animation is achieved by checking the playing state at the start of updateAnimation() function, and returning out of the function if false, preventing the recursive call from being reached.
const platter = ref<SVGElement>()
// duration of a full rotation in ms
const fullRotationDuration = computed(
() =>
(((session.decks[props.deckID].faderPosition *
-0.0001 *
user.authd.settings.turntablePitchRange +
1) *
60) /
session.decks[props.deckID].rpm) *
1000
)
let lastTime = 0 // timestamp of the last frame
let angle = 0 // current angle of rotation
function updateAnimation(time: number) {
// if not playing or no platter exit function
if (!session.decks[props.deckID].playing || !platter.value) return
const elapsedTime = time - lastTime
// update the angle of rotation
angle = (angle % 360) + (elapsedTime / fullRotationDuration.value) * 360
// update transform property of the platter SVG element
platter.value.style.transform = `rotate(${angle}deg)`
lastTime = time
// request next frame
requestAnimationFrame(updateAnimation)
}
// watch playing state, if playing, start animation
watch(
() => session.decks[props.deckID].playing,
(playing: boolean) => {
if (playing) {
lastTime = performance.now()
requestAnimationFrame(updateAnimation)
}
}
)

How to achieve the stroboscopic effect

As mentioned earlier, the stroboscopic effect is relies on the correct distancing between the stroke-dasharray dashes. In this implentation I am targeting 60Hz/120Hz displays. The turntable strobe flashes at 50Hz. It would have taken too long to count all the dots on the turntable platter, then count the dots in the dash array for a given spacing and then perform the calculations required to work this problem out mathematically. So I opted for the trial and error method, which didn’t take long.

I set the dash array circles to an approximate radius visually, then began approximating the distance between the dashes so that they were similar to that of the turntable. From there I could set the pitch fader control to +6.4%, +3.3%, 0% and -3.3% and adjust the dash array spacing for each so that the appropriate dash array appeared static.

This worked well, but the spacing that made the dots appear static at their given pitch adjustments left me with the problem of the dash array not joining up perfectly.

dash array misaligned

From this point I made small adjustments to join the dots perfectly. This was necessary because even very slightly misjoined dash arrays created artefacts in the animation.

dash array aligned

Limitations of this approach

One limitation of using this method to animate the platter is that the accurate stroboscopic functionality is broken when the 45 RPM switch is activated. The 45 RPM setting is again just a visual indication as to the correct settings for a given track. It was intended to be set automatically when a track is loaded onto a deck. Unfortunately the data imported from the Discogs API, doesn’t have accurate RPM data, so unless a user is to manually add this data, the feature is functionally useless.

Possible solutions to overcome the stroboscopic functionality breaking on 45 RPM include:

  • Changing the distance between the dashes when the setting is activated.
  • Running a separate animation for the platter group that is the speed of the animation at 33 RPM, whilst keeping the 45 RPM speed animation active on the record part of the SVG.

Another limitation is the refresh rate target of 60Hz/120Hz. A possible solution for this could be to detect common refresh rates and apply adjustments to the animation of the platter ring accordingly. I haven’t been able to find a built-in way to detect refresh rates in the browser, but this answer on stack overflow provides some direction.

Performance issues

This method does require use of significant resources to function seamlessly. With hardware acceleration turned off and both decks running, the animation uses about ~40% of my CPU (AMD 5600X) on chromium and ~30% on Firefox. With hardware acceleration on, the animation is smooth and CPU usage is minimal.

A functional requirement of Crate Guide is to run on crap laptops, which it does fine, however this animation may bog down such systems. The animation isn’t required for the app to function, and a note about avoiding this feature was left in the documentation.

Possible future enhancements

  • I would like to limit the stroboscopic effect to just the area near the start • stop button, which will be lit up in red. The remaining sections of the platter rings would be blurred as they appear on a Technics turntable.

  • It’d also be nice to add some gradients and lighting effects to the platter and vinyl record.

Summary

Using the refresh rate of monitors with performant requestAnimationFrame() animations to create stroboscopic effects is something I hadn’t considered before. I was able to mimic the stroboscopic effects of a turntable with some accuracy. Still, a lot could be done to improve the animation. I am interested to see whats possible with this technique outside of this use case.

The technique has some issues, the most pressing being the limited target refresh rate. I look forward to continuing work on this one day.

Thanks for getting this far. I’d be interested to hear your feedback at ryanvoitiskis@pm.me.


Continued: Adding speed up and slow down functions

This enhancement isn’t relevant to the stroboscopic effect, but I thought I would include it here. To more accurately mimic the performance of a turntable, I added a speed up and slow down function. The code is pretty self explanatory.

let lastTime = 0 // timestamp of the last frame
let angle = 0 // current angle of rotation
let speedUpRotationDuration = 0 // duration of a full rotation in ms when speeding up
let slowDownRotationDuration = 0 // duration of a full rotation in ms when slowing down
function updateAnimation(time: number) {
// if not playing or no platter exit function
if (!platter.value) return
if (!session.decks[props.deckID].playing) {
slowDownRotationDuration = fullRotationDuration.value
requestAnimationFrame(slowDownAnimation)
return
}
const elapsedTime = time - lastTime
// update the angle of rotation
angle = (angle % 360) + (elapsedTime / fullRotationDuration.value) * 360
// update transform property of the platter SVG element
platter.value.style.transform = `rotate(${angle}deg)`
lastTime = time
// request next frame
requestAnimationFrame(updateAnimation)
}
function speedUpAnimation(time: number) {
if (!session.decks[props.deckID].playing || !platter.value) return
const elapsedTime = time - lastTime
angle = (angle % 360) + (elapsedTime / speedUpRotationDuration) * 360
speedUpRotationDuration -= 200
platter.value.style.transform = `rotate(${angle}deg)`
lastTime = time
if (speedUpRotationDuration <= fullRotationDuration.value) {
requestAnimationFrame(updateAnimation)
} else requestAnimationFrame(speedUpAnimation)
}
function slowDownAnimation(time: number) {
if (!platter.value) return
const elapsedTime = time - lastTime
angle = (angle % 360) + (elapsedTime / slowDownRotationDuration) * 360
slowDownRotationDuration += 200
platter.value.style.transform = `rotate(${angle}deg)`
lastTime = time
if (slowDownRotationDuration <= 8000) requestAnimationFrame(slowDownAnimation)
}
// watch playing state, if playing, start animation
watch(
() => session.decks[props.deckID].playing,
(playing: boolean) => {
if (playing) {
speedUpRotationDuration = fullRotationDuration.value + 6000
lastTime = performance.now()
requestAnimationFrame(speedUpAnimation)
}
}
)
back to notes