Party mode! Hotwired Stimulus 101

Stimulus is a relatively minimalistic JavaScript framework which usually consists of 3 steps:

  • When A happens (Actions)
  • Performs B (Controllers)
  • Reflects the result on C (Targets)

To explore Stimulus's features, I added a Party Mode on this website. The specifications:

  • It can be switched on and off via a keyboard shortcut key.
  • When it is switched on, it does the following:
    • Show the browser in full-screen
    • Expand the header section to 100% height
    • Have some colorful bubbles moving from the bottom to the top of the screen
    • Change the page title into a flashing neon light
    • Play a rock music!

You can experience the final result by pressing Ctrl+B on this page before reading further about it.


Creating the controller

1) First I create a controller called party_controller.js

import {Controller} from "@hotwired/stimulus"

export default class extends Controller {

}

2) Next I want to switch on/off via a shortcut key

<header data-controller="party" data-action="keydown.ctrl+b@document->party#partyMode">

</header>

This connects the header section with our party controller. It also defines an action - when Ctrl+B is pressed anywhere it invokes the "partyMode" method.

import {Controller} from "@hotwired/stimulus"

export default class extends Controller {
    initialize() {
        this.isRunning = false;
    }

    partyMode() {
        this.isRunning = !this.isRunning;
    }
}

I want to know the state of my party mode. This is kept in a variable called "isRunning". The variable has an initial value set in the initialize method, which is a Stimulus controller life-cycle callback. Other types of callbacks can be found here.

Make it full-screen!

3) My first requirement is to turn the current tab into fullscreen when party mode is on and cancel fullscreen when it is off.

import {Controller} from "@hotwired/stimulus"

export default class extends Controller {
    initialize() {
        this.isRunning = false;
    }

    partyMode() {
        this.toggleFullscreen();
        this.isRunning = !this.isRunning;
    }

    toggleFullscreen() {
        if (this.isRunning) {
            if (document.fullscreenElement) {
                document.exitFullscreen();
            }
        } else {
            if (!document.fullscreenElement) {
                document.documentElement.requestFullscreen();
            }
        }
    }
}

This works well, there is no issue calling browser API (e.g. requestFullscreen) from the Stimulus controller.

4) Next, I want to expand the header section to match the screen height when party mode is on and revert to the original height when it is off. 

I will implement this by switching between 2 tailwind classes - h-96 (original height) and h-screen (screen height).

static targets = [ "container" ]

partyMode() {
    this.toggleFullscreen();
    this.isRunning = !this.isRunning;
}

toggleFullscreen() {
    if (this.isRunning) {
        this.containerTarget.classList.remove('h-screen');
        this.containerTarget.classList.add('h-96');
        if (document.fullscreenElement) {
            document.exitFullscreen();
        }
    } else {
        this.containerTarget.classList.remove('h-96');
        this.containerTarget.classList.add('h-screen');
        if (!document.fullscreenElement) {
            document.documentElement.requestFullscreen();
        }
    }
}

Instead of accessing the DOM via getElementById, Stimulus provides a "Target" feature to reference the DOM.

<header data-controller="party" data-party-target="container" data-action="keydown.ctrl+b@document->party#partyMode">

</header>

Create some bubbles

5) My next specification is to create some bubbles that move from the bottom to the top of the screen. These bubbles should appear in a random x position at the bottom of the screen, have a random color, and move upward at a random speed.

In the Stimulus controller, you can define a constant value outside of the controller class, and use it in your controller methods.

import {Controller} from "@hotwired/stimulus"

const MAX_BUBBLES = 35;
const BUBBLE_MIN_SIZE = 10;
const BUBBLE_SIZE_VAR = 20;
const BUBBLE_MIN_SPEED = 2.0;
const BUBBLE_SPEED_VAR = 1.0;

export default class extends Controller {
    static targets = [ "container", "section" ]

    initialize() {
        this.isRunning = false;
        this.bubbles = [];
    }

    partyMode() {
        this.toggleFullscreen();
        this.toggleBubbles();
        this.isRunning = !this.isRunning;
    }

    toggleBubbles() {
        if (this.isRunning) {
            this.cleanBubbles();
        } else {
            this.initBubbles();
        }
    }

    initBubbles() {
        for (let i = 0; i < MAX_BUBBLES; i++) {
            this.createBubble();
        }
        this.animateBubbles();
    }

    cleanBubbles() {
        this.bubbles.forEach((bubble) => {
            this.containerTarget.removeChild(bubble.element);
        });
        this.bubbles.length = 0;
    }

    createBubble() {
        if (this.bubbles.length >= MAX_BUBBLES) return;

        const bubble = document.createElement('div');
        bubble.className = 'absolute rounded-full opacity-50';

        const size = Math.random() * BUBBLE_SIZE_VAR + BUBBLE_MIN_SIZE;
        const x = Math.random() * this.sectionTarget.offsetWidth;
        const y = Math.random() * this.sectionTarget.offsetHeight;

        bubble.style.width = `${size}px`;
        bubble.style.height = `${size}px`;
        bubble.style.left = `${x}px`;
        bubble.style.top = `${y}px`;

        bubble.classList.add(COLORS[Math.floor(Math.random() * COLORS.length)]);

        this.containerTarget.appendChild(bubble);
        this.bubbles.push({ element: bubble, x, y, speed: Math.random() * BUBBLE_SPEED_VAR + BUBBLE_MIN_SPEED });
    }

    animateBubbles() {
        this.bubbles.forEach((bubble) => {
            bubble.y -= bubble.speed;
            if (bubble.y + parseFloat(bubble.element.style.height) < 0) {
                bubble.y = this.sectionTarget.offsetHeight;
            }
            bubble.element.style.top = `${bubble.y}px`;
        });

        requestAnimationFrame(() => this.animateBubbles());
    }
}

Animate the title with random colors

6) To animate the title, I need to first define it as a target. Let's call it "neon".

<h1 class="text-4xl font-bold" data-party-target="neon">Calvin's Dev Logs</h1>

7) Next, a list of colors is needed. It is defined using CSS keyframes.

@keyframes rainbowTextEffect {
    0% { color: white; }
    11% { color: red; }
    22% { color: orange; }
    33% { color: yellow; }
    44% { color: green; }
    55% { color: lightskyblue; }
    66% { color: plum; }
    77% { color: violet; }
    88% { color: darkorange }
    100% { color: white; }
}

8) Then I create a method to toggle between the plain text version and the colored version.

const NEON_DELAY = 3;

...
...

partyMode() {
    this.toggleFullscreen();
    this.toggleBubbles();
    this.toggleNeon();
    this.isRunning = !this.isRunning;
}

toggleNeon() {
    if (this.isRunning) {
        this.neonTarget.textContent = this.orignalNeonContent;
    } else {
        this.orignalNeonContent = this.neonTarget.textContent;
        this.neonTarget.innerHTML = Array.from(this.orignalNeonContent).map(char => {
            const delay = Math.random() * NEON_DELAY;
            return `<span style="animation: rainbowTextEffect 3s infinite alternate; animation-delay: ${delay}s">${char}</span>`;
        }).join('');
    }
}

With a random animation-delay, each character starts animating the keyframes at a different time.

Play Music

9) My last requirement is to play some background music when the party mode is on.

I create a piece of random rock music using Google Music FX.

10) The music file is stored in the Rails assets directory. I decided to print the URL of the file into a Stimulus value.

<header data-controller="party" 
  data-party-target="container" 
  data-action="keydown.ctrl+b@document->party#partyMode" 
  data-party-music-value="<%= audio_url('music_fx.wav') %>">

</header>

11) This can be read from the Stimulus controller via the value feature.

static values = {
    music: String
}

initialize() {
    this.isRunning = false;
    this.audio = new Audio(this.musicValue);
}

partyMode() {
    this.toggleFullscreen();
    this.toggleBubbles();
    this.toggleNeon();
    this.toggleMusic();
    this.isRunning = !this.isRunning;
}

toggleMusic() {
    if (this.isRunning) {
        this.audio.pause();
        this.audio.currentTime = 0;
        this.audio.removeEventListener('ended', () => {});
    } else {
        this.audio.addEventListener('ended', () => {
            this.audio.play();
        });
        this.audio.play();
    }
}

To play the music indefinitely, I just listen to the ended event and replay it again.


The demo above shows the controller, action, target, and value features in Stimulus. It also shows interaction with Browser API and working with Tailwind classes.

It is a decent and easy-to-use JavaScript framework. 


AI Summary
gpt-4o-2024-05-13 2024-07-15 11:13:43
This blog post introduces Stimulus, a minimalistic JavaScript framework, showcasing a "Party Mode" project that can be toggled via a keyboard shortcut. The project demonstrates Stimulus features like fullscreen mode, animated headers, floating bubbles, neon text effects, and background music, all implemented through controller actions and targets.
Chrome On-device AI 2024-11-09 06:05:56

Share Article