SydCSS Wheel of Talks

Last December the SydCSS meetup had its first game show-themed “Wheel of Talks” evening. Three presenters each had to deliver a talk of 5-10 minutes on a topic related to CSS. The catch was that they didn’t know what their topics were until the start of the evening, at which point they had 20-ish minutes to prepare. The talks were randomly selected from the eponymous on-screen Wheel of Talks, built in CSS and JS.

This is the story of how the event was planned, and how I built the wheel.

I can’t hide from my mind

The best ideas arrive when you’re meant to be thinking about something else. The late, great Terry Pratchett imagined them as “inspiration particles” that, like muons and neutrinos, sleet through space until they strike the brain of an unsuspecting creature. I just call them brain farts.

This particular idea struck one morning as I was getting ready for work. I imagined a 3D, poker machine-esque, CSS-powered spinning wheel. Specifically, I imagined two of them — one to randomly choose a presenter, one to randomly choose their topic. I knew SydCSS was looking for new ideas to mix up their evenings, and this would be the perfect fit. I called it “presentation roulette”.

I whipped up a basic prototype on the train ride in to work that morning to make sure the concept worked. I showed it to David (one of the SydCSS organisers) and explained the idea. The next day I got a message from Fiona (the other organiser) saying that she loved the plan. A day later she said that someone else was already on board to do one of the impromptu talks, and that I should do one too.

And thus, a monster was born.

A cunning plan

That was in August. By November, it was about time to stop procrastinating and start building the proper spinner to be used on the night. My plan was to use as many different tricky bits of CSS as possible. That way, when my talk topic was randomly chosen, I’d have a pretty good chance of being able to use the spinner itself as part of the talk.

That plan was later completely foiled when David realised the need for a “filler” talk. Someone would need to give a non-randomly-selected talk after the topic selections, to give the random presenters time to hurriedly prepare. Oh well, at least I knew what to talk about.

Meanwhile, features were rapidly being added to the spinner. I had the advantage of working on the same team as David in my day job, so we could quickly iterate on ideas. The overarching theme of this project ended up being “scope creep”. In the end, I’m very pleased with the end result, even if we had projector and sound issues on the night.

But enough waffling on about the context, now it’s time to explain how it was built.

If you just want to jump straight to the final demo, head to https://gilmoreorless.github.io/presentation-roulette/ and give it a whirl. A few times. With your volume up.

Or you could just watch a video of it right here:

The things I did and didn’t do

I set myself a challenge with this project. I wanted to write the code entirely using browser devtools and raw HTML, CSS, and JS. No build tools, no watch scripts, no fancy transpilation of future syntax. Just use whatever the browser provided built-in, including code editing. It worked surprisingly well.

Ready, get Set, go

There were two key criteria for the matching of presenters to talk topics on the night. The selections had to be completely random, and there shouldn’t be any duplicates.

To guarantee unique selections, I collected the list of topics into a Set object. When a selection was needed, the code would use Math.random() to choose a random topic. That topic was then removed from the set, ensuring that it wouldn’t get chosen again.

I used a Set partially because it allowed me to easily remove an item without going through the hassle of looking up the documentation for Array.prototype.slice (because I never remember how to use it properly). Unfortunately, Sets don’t allow looking up an item via an index (because they’re meant to not have any implicit ordering). So to get a random item, I had to convert the Set into an array, generate a random index (Math.floor(Math.random() * topics.length)), look up the topic at that index, then remove the topic from the original set. In hindsight, I should have just used an array and looked up how to use .slice() properly. Once again my laziness produced more work in the long run.

Everybody at the party is a many-sided polygon

The core of the spinner effect was taking a plain list of items and wrapping them into a 3D cylinder. Each list item is a rectangle — if we think about looking at the list side-on, it would form a vertical line. What I needed was to turn that single line into a regular polygon:

Side-on view of a list of items curved into a cylinder

Each item would have to originate from the same point (the centre of the polygon), rotated to the appropriate angle, then moved out from the centre point by the same amount. The rotation angle was easy to calculate — it’s the 360 degrees of a full circle, divided by the number of items. The tricky part was working out how far to move each item to avoid any gaps or overlaps.

In order to make a regular polygon, the midpoint of each edge (list item) needed to be touching a circle centred around the centre of the polygon:

Octagon overlaid with a circle matching the inner radius

Therefore each edge needed to be pushed out from the centre by a distance equal to the radius of the circle. But what was that radius?

For this I had to revisit high school trigonometry. If we draw lines from the centre of the polygon to the edge’s midpoint and one of its ends, we end up with a right-angled triangle. We know two key things about this triangle:

  1. The smallest angle (θ), which is half of the main rotation angle (360° divided by the number of items).
  2. The length of the side directly opposite the smallest angle, which is half of the item’s height.

Right-angled triangle formed by joining the centre of an octagon to the end and midpoint of one of its sides

From this, we want to find the length of the side adjacent to the smallest angle. Using the “TOA” part of the SOHCAHTOA formula, we know that tan(θ) = Opposite / Adjacent. Flip that around and we get Adjacent = Opposite / tan(θ). Since we know both θ and Opposite (half the item’s height), getting the remaining value is as simple as:

const radius = (itemHeight / 2) / Math.tan(itemAngle / 2);

Once we move all the items out from the centre by that distance, they all line up neatly:

this.itemNodes.forEach((node, i) => {
    let angle = itemAngle * i;
    node.style.transform =
        // Move up by half the item’s height, as its container is 0px high
        `translateY(-${itemHeight / 2}px)`
        // Rotate to the correct angle
        + ` rotateX(-${angle}rad)`
        // Move the item out from the centre of the container
        + ` translateZ(${radius - 1}px)`;
});

(Side note: For this technique to work properly, every item must have the same height. This was mainly achieved via the low-tech approach of just rewording some of the topics to fit the available space.)

You spin me right round, baby, right round

Having built the wheel, the next task was to make it spin using the Web Animations API. This relatively new JS API, at its simplest, allows us to easily create CSS animations on-the-fly. It can do a whole lot more, but for my purposes Element.animate() was going to be enough.

The wheel had been deliberately built to have every face evenly distributed around a central horizontal axis. This meant that spinning the wheel became a simple rotation around the wheel’s X axis:

const constantSpinDuration = topics.length * 1500;
wheelContainer.animate(
    { transform: ['rotateX(0turn)', 'rotateX(1turn)'] },
    {
        duration: constantSpinDuration,
        easing: 'linear',
        iterations: Infinity,
    }
);

The next step was to make it spin to a specific item. This was a matter of working out how far around the wheel an item was, then changing the animation to rotate to that spot. For consistency, I also kept track of the previously-selected item and used it as the animation’s starting point.

function rotation(percentage) {
    return `rotateX(${percentage}turn)`;
}

const oldItemAngle = this.selectedIndex / this.itemNodes.length;
const newItemAngle = indexOfItem / this.itemNodes.length;
wheelContainer.animate(
    { transform: [ rotation(oldItemAngle), rotation(newItemAngle) ] }
);

In the real world, if you spin a physical wheel it starts fast and slows down, gradually crawling to a stop. I wanted the same effect for this digital version. The easiest way to do this was to choose a custom easing function by playing around with different values in my browser’s devtools.

That produced the right fast-to-slow effect, but it didn’t feel right. It would only do a maximum of one full turn of the wheel, and in some cases even turned backwards! I added a minimum number of rotations that the wheel would do before settling on the final item (5 seemed to work well enough). I also added some variance in how long the spin would take, so that it wasn’t exactly the same each time.

const minRotations = 5;
// 10 seconds duration...
const minSpinDuration = 10000;
// ...with up to 1 second variance either side (faster or slower)
const spinDurationVariance = 2000;

let turn = minRotations + newItemAngle;
if ((turn - oldItemAngle) < minRotations) {
    turn += 1;
}
const variance = Math.random() * spinDurationVariance - spinDurationVariance / 2;
const duration = minSpinDuration + variance;
wheelContainer.animate(
    { transform: [ rotation(oldItemAngle), rotation(newItemAngle) ] },
    {
        duration: duration,
        // Custom easing function which starts very fast and slows down
        easing: 'cubic-bezier(0.11, 0.69, 0.13, 0.98)',
        // Stay in the end position once the animation finishes
        fill: 'forwards',
    }
);

Like a record, baby, right round round round

After discussing the idea with a few people, I knew we were also going to need a few different animations. That way the spinner would behave slightly differently for each spin.

To do this I split the animation call out into a an array of animation functions. The calculation of how far to spin still happens as before, but the definition of how to spin there is changeable.

const spinnerAnimations = [
    function normal({ node, start, end, duration }) {
        return node.animate(
            { transform: [ rotation(start), rotation(end) ] },
            {
                duration: duration,
                easing: 'cubic-bezier(0.11, 0.69, 0.13, 0.98)',
                fill: 'forwards',
            }
        );
    },

    // ... more functions ...
];

Fake-believe

Now that the functionality was done, it was time to make it look close enough to a real-life spinner. This part was an even blend of precise mathematical operations using calc(), and randomly tweaking values until it looked about right.

I had initially intended to make a decent-looking generic spinner, then make a separate theme specifically for SydCSS. In the end I focused so much on the final SydCSS version that I didn’t really do a generic theme. But I did at least set up a rough theming system using CSS custom properties.

The width, height, and colours of the spinner are all controlled by custom properties. This allowed me to add in extra stylistic elements and base them all on the same values. Making the spinner wider (which needed to happen an hour before presenting it) was a matter of changing a single property:

.spinner--theme-sydcss {
    --spinner-width: 500px;
    --spinner-height: 270px;
    --colour-background: hsl(18, 97%, 5%);
    --colour-foreground: white;
    --colour-border: hsl(13, 77%, 45%);
}

The colours of the individual items were changed to match the main promotion image used by David to enhance the “game show” effect. This was a perfect use case for the :nth-child() pseudo-selector:

.spinner--theme-sydcss .spinner__item {
    background-color: #FB4C32;
    color: hsla(198, 100%, 95%, 1);
    padding: 0.5em 0.85em;
}
.spinner--theme-sydcss .spinner__item:nth-child(4n+2) {
    background-color: #1FC754;
}
.spinner--theme-sydcss .spinner__item:nth-child(4n+3) {
    background-color: #E41481;
}
.spinner--theme-sydcss .spinner__item:nth-child(4n+4) {
    background-color: #9B1FD7;
}

List of items curved into a cylinder with alternating bright colours

Giving the spinner the final “3D cylinder” effect was achieved by crafting a combination of a few different techniques:

  1. 3D transforms & perspective.
  2. Clip paths.
  3. Gradients.
  4. Box shadows.

I’ll list the details here in a logical order that shows a neat progression. In reality it was a constant back-and-forth of experimentation, as it wasn’t entirely clear what would work.

Experiment #1: 3D transforms & perspective

This one was obvious — it’s very hard to have a 3D spinning wheel without using 3D transforms. As described earlier, each item was using rotateX and translateZ transforms. But 3D effects don’t work in CSS until some extra properties are added.

First of all, the containing element needs transform-style: preserve-3d; applied. Without this, the child elements are all rendered as if they’d been flattened into the screen.

Secondly, the container needs the perspective property to control how exaggerated the 3D effect is. The perspective property works by creating an imaginary view point at a specified distance from the screen, then rendering all 3D effects from that view point. Thus a value of perspective: 500px renders 3D objects as if the viewer were exactly 500px from the object. I also needed to set a perspective-origin value to be the exact centre of the spinner, so that it was symmetrical.

Experiment #2: Clip paths

With the spinner now properly 3D, I needed a way to hide the rough, rectangular edges. The left and right edges needed to be rounded to properly look like a cylinder. I experimented with using radial gradients the same colour as the background (to effectively paint over the bits that needed hiding). In the end, I settled on using the clip-path property.

The best result was achieved with a clipping path of an ellipse that’s taller than the spinner, but not as wide. That way the sides get clipped in smooth curves while the top and bottom are unaffected.

.spinner--theme-sydcss {
    --clip-path-w: calc(var(--spinner-width) * 0.6);
    --clip-path-h: calc(var(--spinner-height) * 1.1);
}

.spinner--theme-sydcss .spinner__list-container {
    clip-path: ellipse(
        var(--clip-path-w)
        var(--clip-path-h)
        at calc(var(--spinner-width) / 2)
    );
}

Experiment #3: Linear gradients

I’ve previously done an entire presentation on how gradients work, so they’re often my go-to choice for visual effects.

The spinner sides were fixed, but the top and bottom still had a visual problem. Due to the 3D perspective of the prism shape, there was a noticeable “wobble” during rotation of the spinner. The rendered height of the spinner would vary as the rectangular faces changed angles.

Ideally I’d just use another clipping path to cut off the extremes, but the clip-path property only accepts a single value. (In future clip-path will support using SVG path syntax, but calculating, tweaking, and maintaining those values would have been a nightmare. It also had almost no browser support at the time of this project.)

I also wanted a shading effect, so the spinner would look like it was slightly embedded in the flat surface of the screen. Luckily, both effects could be implemented with a single linear gradient.

An additional element was positioned over the top of the spinner. Adding black with varying levels of transparency produced a pseudo-3D shadow effect. Then the very top and bottom of the spinner were covered by a fully opaque block of background colour, giving the same result as a clipping path.

Since the gradient’s bottom half was a mirror image of the top half, this was another good excuse to use custom properties for the colour definitions:

.spinner--theme-sydcss .spinner__overlay {
    --cf-bg1: var(--colour-background);
    --cf-bg2: var(--colour-border);
    --cf-dark: rgba(0, 0, 0, 0.8);
    --cf-light: rgba(0, 0, 0, 0.3);
    --cf-clear: rgba(0, 0, 0, 0);
    background-image:
        linear-gradient(to bottom,
            var(--cf-bg2) 7%,
            var(--cf-bg1) 0,
            var(--cf-bg1) 8%,
            var(--cf-dark) 0,
            var(--cf-light) 20%,
            var(--cf-clear) 40%,
            var(--cf-clear) 60%,
            var(--cf-light) 80%,
            var(--cf-dark) 92%,
            var(--cf-bg1) 0,
            var(--cf-bg1) 93%,
            var(--cf-bg2) 0
        );
}

Finally, a clipping path was also applied to the gradient overlay so that it lined up with the shape of the spinner underneath. Hooray for custom properties!

Experiment #4: Radial gradients

The 3D effect was still missing something. If the spinner was “popping out” of the screen, and had the concept of shadow on top of it, then it should also cast a shadow below it.

On the background layer, I added two identical radial gradients using semi-transparent black. These were positioned in such a way that they just peeked out from the left and right edges of the spinner. This provided just enough of a shading effect that they looked like the curved shadows of the spinner.

.spinner--theme-sydcss .spinner__border {
    background-color: var(--colour-border);
    background-image:
        radial-gradient(
            calc(var(--spinner-width) * 0.19) calc(var(--spinner-height) * 0.5)
            at 18% 50%,
            rgba(0,0,0,0.8) 60%, transparent
        ),
        radial-gradient(
            calc(var(--spinner-width) * 0.19) calc(var(--spinner-height) * 0.5)
            at 82% 50%,
            rgba(0,0,0,0.8) 60%, transparent
        );
}

Experiment #5: Box shadows

The last little visual effect was completely unnecessary, but I added it on a whim a day before the presentation. This was the effect of having small “studs” bolting the spinner’s background on to the page.

The studs were all based on a single hidden pseudo-element. The pseudo-element was positioned exactly in the middle, then a heap box-shadow copies of it were stamped around the corners. Each stud is made up of 2 box shadows layered on top of each other.

.spinner--theme-sydcss .spinner__border::before {
    --stud-size: 0.25em;
    --stud-dark: hsl(18, 80%, 15%);
    --stud-light: hsl(18, 80%, 20%);
    --stud-spread: 5px;

    content: '';
    width: var(--stud-size);
    height: var(--stud-size);
    position: absolute;
    border-radius: 50%;
    box-shadow:
        /* top left */
        calc(var(--spinner-width) * -0.64) calc(var(--spinner-height) * -0.5) var(--stud-light),
        calc(var(--spinner-width) * -0.64) calc(var(--spinner-height) * -0.5) var(--stud-spread) var(--stud-spread) var(--stud-dark),
        /* top right */
        calc(var(--spinner-width) * 0.64) calc(var(--spinner-height) * -0.5) var(--stud-light),
        calc(var(--spinner-width) * 0.64) calc(var(--spinner-height) * -0.5) var(--stud-spread) var(--stud-spread) var(--stud-dark),
        /* bottom left */
        calc(var(--spinner-width) * -0.64) calc(var(--spinner-height) * 0.5) var(--stud-light),
        calc(var(--spinner-width) * -0.64) calc(var(--spinner-height) * 0.5) var(--stud-spread) var(--stud-spread) var(--stud-dark),
        /* bottom right */
        calc(var(--spinner-width) * 0.64) calc(var(--spinner-height) * 0.5) var(--stud-light),
        calc(var(--spinner-width) * 0.64) calc(var(--spinner-height) * 0.5) var(--stud-spread) var(--stud-spread) var(--stud-dark);
}

Why did I do it this way? Erm… it seemed like a good idea at the time.

Experiment #6

The sound of sigh-lence

One of the aims of the evening was “controlled chaos” — make it as random and haphazard as possible, while still controlling some of the parameters. To enhance this aim, and the general game show vibe, we needed comedic sound effects.

How should one get hold of some varied sound effects, while also being a complete cheapskate and not wanting to pay for them? Open licence sound libraries to the rescue! Specifically, I rummaged through Freesound and the BBC Sound Effects archive. The spinning sound of the wheel is actually an old BBC recording of a casino roulette wheel. This tied in very nicely with the original name of the project (“presentation roulette”).

Making the sound effects play at the right time was a job for the Web Audio API, tied to the animation calls defined earlier. I made a super-basic SoundEffects class which pre-loaded and buffered the various sound files. Then the right sound effect would be played as part of the animation effect functions that were already defined.

Now the animation effects are no longer single functions. They are objects with start and end functions which are called (unsurprisingly enough) at the start and end of the effect. Each start function returns an animation and a sound effect, which are passed into the end function once the animation has finished. This allows the end function to do some cleanup work (like stopping any sound effects, or showing a “you have selected this item” arrow).

function showArrow() {
    soundEffects.play('ding');
    rootNode.dataset.hasArrow = true;
}

const spinnerEffects = [
    {
        name: 'normal',
        start: ({ node, start, end, duration, itemCount }) => {
            return {
                animation: node.animate(
                    { transform: [ rotation(start), rotation(end) ] },
                    {
                        duration: duration,
                        easing: 'cubic-bezier(0.11, 0.69, 0.13, 0.98)',
                        fill: 'forwards',
                    }
                ),
                sound: soundEffects.play('spin'),
            };
        },
        end: ({ animation, sound, showArrow }) => {
            sound.stop();
            showArrow();
        },
    },

    // ...more animations...
];

The best thing about this system is that the end function can also trigger more animations if required. It enabled a spur-of-the-moment idea that I implemented on the train on the morning of SydCSS — last-minute scope creep! I could have the final spin of the evening look like it completely destroyed the spinner. We knew there would only be 3 presenters on the night, so I could pre-determine the order of animations.

First I reversed the initial animation, so it started slow and then sped up. This was only a matter of inverting the easing function but leaving everything else the same. I also made a new spinning sound effect by playing the original sound in reverse.

In the end function, I re-animated the corpses items on the spinner. Each item (except for the selected one) got randomly rotated, then flung out from the centre of the spinner (using translateZ(2000px)). To add the final touches, a “crashing glass” sound effect was played, and the entire spinner board rotated slightly to make it look like it was falling off.

The response on the night to the unexpected animation was even better than I’d hoped for.

We’ll meet at the end of the tour

There you have it: my (rather overdue) explanation of how the Wheel of Talks was built. The evening was such a success that we’ll most likely turn it into an annual event.

Will it be the same wheel each year? Given my love of being distracted by ridiculous projects — and the suggestions I’ve already received — it’s a safe bet that there might be a different version every time.

We’ll find out in December.

P.S. If you’re bored, you could go back and work out how many of the sub-headings on this post are music references.