March 21, 2026

BLOCKS: a two-day art project

Creating interactive visuals with JavaScript.

(40 minute read)

I've been ruminating on interactive, generative (no, not that kind of generative) visual art for a while now. Having been to my fair share of concerts and exhibitions, I'm always stunned by how live-visual artists approach the task of elevating a story through conscious, deliberate design. Especially with concert visuals — being software-driven — I can't help but wonder: why shouldn't I do that too?

So here's the plan: we're going to create a small, interactive, audiovisual art piece on the front page of this site.

A short design study

Not even two weeks ago I found some letterhead for NY real estate developer PLOT Advisory on Pinterest.

Four letterheads for PLOT advisory featuring their logo. Four adjacent multicolored squares each bearing a letter of PLOT are in the top left corner, and each iteration of the logo positions its letters slightly differently.

I love simple geometries and primary-secondary color palettes. I adore the way each iteration of the letterhead slightly reconfigures the position of the logo, suggesting movement from one to the other. If we're to make an interactive something with this idea, the implementation would probably function like this:

Diagram with two figures. The first shows five adjacent multicolored squares on a grid of dotted lines, captioned "blocks must bind to a grid". The second shows the same configuration of squares, each with one or more arrows pointing to dotted phantom squares directly adjacent, captioned "blocks can only move to adjacent, unoccupied positions".

This is enough to establish the hypothetical basics of the movement and interactivity, but there is an additional layer of complexity that comes with the fact the there are letters in the blocks. Ideally, I'd like to maintain legibility, which is in itself a design and behavior conversation. Not all configurations of blocks will result in good design due to how the word might be read:

But then there's an issue of unusual configurations which still parse without breaking the reader's eye movement. The third example from PLOT demonstrates this by having the "L" dip low, technically introducing a bottom-left to top-right pattern but still preserving hierarchy since Latin languages enforce it by being primarily read left-to-right.

A diagram with three groups of examples. In the first group, five adjacent multicolored blocks spelling "BLOCK" are arranged with an overlaid dotted line from top left to bottom right, demonstrating visual hierarchy and ease of reading, captioned "good configurations maintain a top-left to bottom-right eye movement. The second group shows configurations that break visual hierarchy, with dotted lines showing V-shaped deflections, captioned "bad configurations have deflections that break the top-left bottom-right rule". The third group has a mostly linear configuration with a dotted line spanning from low left to upper right, captioned "unusual configurations break the rule but work because Latin languages enforce strong visual hierarchy by reading from left to right; anthropology is stronger than the design principle".

But there is another exception in symmetry. The fourth example from PLOT shows the top-left bottom-right rule being broken, though still being legible because reading one line to the next in a Z-pattern is how Latin languages work. Make no mistake, there is still friction here in terms of eye movement, but it's smoothed over by the symmetry of the configuration. Four-letter words are easy to read at a glance and humans prefer symmetrical forms.

But none of this matters if we can't force symmetry, such as in the case of a five-letter word. The configuration becomes laborious to read even though we are treading the Z-pattern. The Z-pattern introduces visual friction, even though Latin-language readers are conditioned for it.

A diagram with two groups of examples. In the first group, four multicolored adjacent squares spelling "BLOC" without a "K" are arranged in a 2-by-2 square, captioned "four letter words are legible for being short and allowing for strong symmetry; minimal eye movement is needed, and so balance becomes the key. In the second group, five multicolored adjacent squares spelling "BLOCK" with a "K" are arranged mostly in a 2-by-2 square except the odd "K", captioned "five letter words lose the benefit of symmetry, eye movement is impeded.

Then we start getting into the psychology of line breaks and the rabbit hole just keeps going. The point of this exercise is to take what we can out of a source of inspiration — a real estate firm's letterhead in this case — and then weigh pros and cons, available tools, relevant skills, and make something new with it.

Implementing

It's clear we're looking to work with letters in blocks tied to a grid. We want some sort of movement with these elements. We also want to keep our scope trim and achievable; there's a lot of ground to cover with visual hierarchy and allowing block movement, and we may not be able to get to all of it.

Remember: we aren't writing enterprise software, we are merely experimenting.

Creating blocks

We'll need to set up a place for our blocks to appear on the homepage: a stage. We'll create a div element spanning the width and height of the viewport, absolutely positioned. For sake of simplicity, we'll hide the stage on mobile.

<body>
    {{ nav | safe }}
    <content id="homepage">
        <div id="hero">
            <div id="hero-img">
                <img id="hero-img-img" src="{{ from_cdn }}/img/site-resources/{{ hero }}">
            </div>
            <p>Erikson Arias (pictured right)</p>
            <p>is a <span id="is-a">data engineer</span>.</p>
            <p>This is his website.</p>
            <div id="hero-logo">
                <img src="{{ from_cdn }}/img/logo_white_c.png">
            </div>
        </div>
        <!-- we create the stage here -->
        <div id="stage" class="desktop-only">
        </div>
        <!-- -->
    </content>
    <footer id="home-footer">
        <a href="/human" id="human">
            <img src="{{ from_cdn }}/img/human/human_bg_tiny.png">
        </a>
        Copyright © {{ year }}, Erikson Arias. All rights reserved.
    </footer>
</body>

Then we can create our stage styling to make sure it's positioned and z-indexed properly, and we can throw in some rules for our future .block elements. We won't define a size for the blocks just yet; we can do that programmatically in JavaScript for tuning.

#stage {
    display: flex;
    position: absolute;
    align-items: center;
    justify-content: center;
    width: 100vw;
    height: 100vh;
    top: 0;
    left: 0;
    z-index: 10;

    .block {
        aspect-ratio: 1/1;
        position: absolute;
        background-color: white;
        box-sizing: border-box; /* this will come in handy later */
    }
}

Now we can add a small listener function that creates a new .block with a key press, making sure to call our script in our HTML.

const BLOCK_SIZE = 75;
const STAGE = document.getElementById("stage");

function createBlock() {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;

    STAGE.appendChild(el);
}

document.addEventListener("keydown", function (event) {
    createBlock();
});
<script src="/static/js/blocks.js"></script>

An animation of a webpage. After a moment, a white square appears at center.

While we're here, let's get the hero image and text out of the way when the block appears. It'll be like a "mode", where activating the block puts the page in "block mode". A boolean flag will do nicely.

Of course, the more we activate the listener, the more blocks we add to the DOM, and so it'd be in our best interest to set a cap on the number of blocks we can make.

(We'll also quietly z-index our stage to sit behind our navigation and footer z-layers. Code not shown, but should be simple enough.)

const BLOCK_SIZE = 75;
const STAGE = document.getElementById("stage");

function createBlock() {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;

    STAGE.appendChild(el);
}

var isActive = false;
var blockCount = 0;
document.addEventListener("keydown", function (event) {
    // Activate block mode
    if(!isActive) {
        document.getElementById("hero").style.opacity = 0;
        document.getElementById("hero").style.userSelect = "none";
        isActive = true;
    }

    // Max block count at 9
    if(blockCount < 9) {
        createBlock();
        blockCount++;
    }
});

An animation of a webpage. After a moment, a white square appears at center and all other visual elements fade out except for the page navigation at top and the footer at bottom.

So now we're creating a stack of at most 9 blocks in the center of the stage element; they stack due to their absolute positioning. We'll need to spread them out on a grid, and we can do that by defining the positions the blocks can occupy. We've already defined our blocks with BLOCK_SIZE, our grid positions should scale accordingly.

We'll enumerate our available grid positions and a flag indicating if the position is occupied. Then for this exercise, we'll simply iterate each position one after another; we don't need to set the occupancy flags yet since definitionally we can't occupy the same position twice. Then we'll use CSS transform to offset each block into their position.

...

const ALL_POSITIONS = [
    { idx: 0, "x": BLOCK_SIZE*-1, "y": BLOCK_SIZE*-1, "isOccupied": false },
    { idx: 1, "x": 0, "y": BLOCK_SIZE*-1, "isOccupied": false },
    { idx: 2, "x": BLOCK_SIZE, "y": BLOCK_SIZE*-1, "isOccupied": false },
    { idx: 3, "x": BLOCK_SIZE*-1, "y": 0, "isOccupied": false },
    { idx: 4, "x": 0, "y": 0, "isOccupied": false },
    { idx: 5, "x": BLOCK_SIZE, "y": 0, "isOccupied": false },
    { idx: 6, "x": BLOCK_SIZE*-1, "y": BLOCK_SIZE, "isOccupied": false },
    { idx: 7, "x": 0, "y": BLOCK_SIZE, "isOccupied": false },
    { idx: 8, "x": BLOCK_SIZE, "y": BLOCK_SIZE, "isOccupied": false }
]

...

function createBlock(position) {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;

    STAGE.appendChild(el);
}

var isActive = false;
var blockCount = 0;
document.addEventListener("keydown", function (event) {
    if (!isActive) {
        document.getElementById("hero").style.opacity = 0;
        document.getElementById("hero").style.userSelect = "none";
        isActive = true;
    }

    // Max block count at 9
    if (blockCount < 9) {
        createBlock(ALL_POSITIONS[blockCount]);
        blockCount++;
    }
});

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Adjacent white squares appear one at a time in left-to-right rows of three, forming a 3-by-3 square at center.

Now we have blocks and a small grid to work with, but we can be a bit more dynamic with this setup by not filling all available positions and randomizing the fill pattern. Starting with the randomness, we can whip up a couple of quick helper functions in a separate script file to keep things tidy (and quietly add the script to the homepage HTML, above our blocks.js import to ensure these functions are available to it).

// Return a random integer up to max.
function randInt(max) {
  return Math.floor(Math.random() * max);
}

/*
  Choose a random element from a list.

  If given a last selected value, will select an element from the list
  that does not match the last value.
*/
function choose(list, last=null) {
  if(last == null) {
    return list[randInt(list.length)];
  }

  var choice = null;
  while (choice == null || choice == last) {
    choice = list[randInt(list.length)];
  }

  return choice;
}

Then using these, we can also leverage our occupancy flags to make sure we aren't trying to fill an occupied space when we select a position at random.

const MAX_BLOCKS = 7;
...

function createBlock(position) {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    position.isOccupied = true; // set occupancy flag

    STAGE.appendChild(el);
}

var isActive = false;
var blockCount = 0;
var lastPos = null; // track last populated position
document.addEventListener("keydown", function (event) {
    if (!isActive) {
        document.getElementById("hero").style.opacity = 0;
        document.getElementById("hero").style.userSelect = "none";
        isActive = true;
    }

    if (blockCount < MAX_BLOCKS) {
        var newPos = choose(ALL_POSITIONS, lastPos);

        // If random position is occupied, try another random pick.
        while(newPos.isOccupied) {
            newPos = choose(ALL_POSITIONS, lastPos);
        }

        createBlock(newPos);
        blockCount++;
        lastPos = newPos;
    }
});

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven white squares appear one at a time positioned randomly within a 3-by-3 configuration at center.

Lastly, we can add some colors and transitions to the blocks as they're created. They'll start solidly colored and then fade into same-colored outlines (hence our box-sizing: border-box in our CSS), with their color randomly selected. I have my site's colors defined as CSS variables in my main styles.css, so we can reference them using var(--green) and the like.

const COLORS = ["var(--green)", "var(--yellow)", "var(--blue)", "var(--red)", "var(--purple)"];
...

function createBlock(position) {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    position.isOccupied = true; // set occupancy flag

    // Set colors
    var color = choose(COLORS);
    el.style.backgroundColor = color;
    el.style.border = `3px solid ${color}`;
    el.style.transition = "transition: all 0.25s ease"; // for a smooth animation

    STAGE.appendChild(el);

    // Change from solid to outline after about a quarter second delay.
    setInterval(function() {
        el.style.backgroundColor = null;
    }, 200);
}

...

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares appear one at a time positioned randomly within a 3-by-3 configuration at center. As each square appears, it is briefly a solid color before fading to a colored outline.

Adding letters

Throwing letters into the blocks is as simple as setting innerText on each element, for fun we can set it equal to the last key pressed. The keydown event gives us access to key, and a simple regex test can suppress the block behavior for all keys outside of alphabetic characters. Then we can pass in a new letter argument to createBlock() to set each block's innerText.

We'll also adjust our .block CSS to use flexbox to center the innerText, and we'll add a bottom padding to compensate for font line height variances. Here I'm using Junicode, and a 25px bottom padding seems to do the trick. For simplicity, we'll force the innerText to be uppercase to avoid below-baseline character centering shenanigans. I've also done other text nudging to get the feeling just right, but other fonts will have other needs.

Finally, we'll start the text's color off on white and then ease into the block's overall color to reinforce the animation.

...

function createBlock(position, letter) {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    var color = choose(COLORS);
    el.style.backgroundColor = color;
    el.style.border = `3px solid ${color}`;
    el.style.transition = "transition: all 0.25s ease";

    el.innerText = letter; // add letter

    position.isOccupied = true;
    STAGE.appendChild(el);

    // Change from solid to outline after about a quarter second delay.
    setInterval(function() {
        el.style.backgroundColor = null;
    }, 200);
}

...

document.addEventListener("keydown", function (event) {
    // Filter for letters only
    if(/^[a-z]/.test(event.key)) {
        if(!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        if(blockCount < MAX_BLOCKS) {
            var newPos = choose(ALL_POSITIONS, lastPos);

            // If random position is occupied, try another random pick.
            while(newPos.isOccupied) {
                newPos = choose(ALL_POSITIONS, lastPos);
            }

            createBlock(newPos, event.key);
            blockCount++;
            lastPos = newPos;
        }
    }
});
#stage {
    display: flex;
    position: absolute;
    align-items: center;
    justify-content: center;
    width: 100vw;
    height: 100vh;
    top: 0;
    left: 0;
    z-index: 10;

    .block {
        font-family: "Junicode", serif;
        font-style: italic;
        font-weight: bold;
        line-height: 0.2;
        padding-bottom: 25px;
        aspect-ratio: 1/1;
        position: absolute;
        background-color: white;
        box-sizing: border-box;
    }
}

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares, each bearing a random uppercase letter, appear one at a time positioned randomly within a 3-by-3 configuration at center. As each square appears, it is briefly a solid color before fading to a colored outline.

But it's here that we run into a small issue: if we want to choose what word is spelled out by the blocks, we need to ensure the letters are in order even though the blocks are created at random. I like the direction this has gone so far, but implementing the ideal readability rules from the design study might be out of scope. For now, we can settle for getting the letters in the correct order.

The procedure we'll implement will go something like this:

Our precompute() function would handle the first two tasks:

...

function precompute(_positions, word) {
    // Make a copy of the positions list and randomize its order.
    // structuredClone is needed for copies of lists of objects.
    var randPositions = structuredClone(_positions);
    randPositions.sort(() => Math.random() - 0.5);

    // Adjust number of output positions relative to the input word,
    // bounded if there are more letters than positions, further bounded
    // if both exceed our MAX_BLOCKS setting.
    var n = Math.min(_positions.length, word.length, MAX_BLOCKS)

    // Take the top N elements in our randomized positions list.
    randPositions = randPositions.slice(0, n);

    // Re-sort our randomized positions in index order.
    randPositions.sort(function (a, b) {
        var idxA = a.idx;
        var idxB = b.idx;

        if (idxA < idxB) return -1;
        if (idxA > idxB) return 1;
        return 0;
    });

    // Assign each letter in our word to each position.
    for (var i = 0; i < randPositions.length; i++) {
        randPositions[i].letter = word.charAt(i);
    }

    return randPositions
}

...

To punctuate the use of structuredCopy(), there are other methods of shallow copying arrays in JavaScript such as slice() and [...array] that are perfect for lists of primitives but will not work for lists of objects. These other methods will instead copy references to those objects; any attribute-setting will be applied globally.

Now we have a list of positions with letters guaranteed to be in word order, so all that remains is to layer this new function into our existing logic.

...

function createBlock(position) { // we drop the letter parameter...
    console.log(position);
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    var color = choose(COLORS);
    el.style.backgroundColor = color;
    el.style.border = `3px solid ${color}`;
    el.style.transition = "transition: all 0.25s ease";

    el.innerText = position.letter.toUpperCase(); // ...because we get the letter from our position object
    el.style.fontSize = "55px";
    el.style.color = "white";

    position.isOccupied = true;
    STAGE.appendChild(el);

    setInterval(function () {
        el.style.backgroundColor = null;
        el.style.color = color;
    }, 200);
}

...

var positions = precompute(ALL_POSITIONS, "BLOCKED"); // precompute positions
document.addEventListener("keydown", function (event) {
    if(/^[a-z]/.test(event.key)){
        if (!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        if (blockCount < MAX_BLOCKS) {
            var newPos = choose(positions);

            while(newPos.isOccupied) {
                newPos = choose(positions);
            }

            createBlock(newPos); // we no longer pass in a letter here
            blockCount++;
            lastPos = newPos;
        }
    }
});

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares, each bearing an uppercase letter, appear one at a time positioned randomly within a 3-by-3 configuration at center, eventually spelling "BLOCKED". As each square appears, it is briefly a solid color before fading to a colored outline.

Resetting

If we can now reliably spell out one word, why not multiple words in rotation? To do that, we'd need to be able to reset the blocks on the step after our MAX_BLOCKS limit is reached. All we need to do is check if the limit was reached, reset the block counter, and remove all blocks from the DOM for the next cycle.

...

const WORDS = ["BLOCKED", "???????", "!!!!!!!"];

...

var lastWord = choose(WORDS); // store our last word to avoid showing the same word twice consecutively
var positions = precompute(ALL_POSITIONS, lastWord);
document.addEventListener("keydown", function (event) {
    if(/^[a-z]/.test(event.key)){
        if (!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        if (blockCount < MAX_BLOCKS) {
            var newPos = choose(positions);

            while(newPos.isOccupied) {
                newPos = choose(positions);
            }

            createBlock(newPos);
            blockCount++;
            lastPos = newPos;
        } else {
            // Remove all current blocks from the DOM.
            Array.from(document.getElementsByClassName("block")).forEach((el) => {
                el.remove();
            });
            blockCount = 0; // reset block count
            lastWord = choose(WORDS, lastWord); // remember last word
            positions = precompute(ALL_POSITIONS, lastWord); // precompute next configuration
        }
    }
});

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares, each bearing an uppercase letter, appear one at a time positioned randomly within a 3-by-3 configuration at center, eventually spelling "BLOCKED". As each square appears, it is briefly a solid color before fading to a colored outline. After 7 squares appear, they instantly disappear. They are then replaced by a new configuration of squares.

(Ignore that "BLOCKED" appears twice consecutively in this animation, I forgot to set lastWord for that demo and I'm disinclined to take a mulligan.)

We can make the end of each cycle a little less abrupt with a bit of animation. We'll put that functionality in a new function:

...

function removeBlock(block, t=250) {
    block.style.transition = "none";
    block.style.color = "white";
    block.style.backgroundColor = "white";
    block.style.border = "3px solid white";
    block.style.boxShadow = "0px 0px 29px 2px white";

    block.animate(
        [
            { opacity: 1 },
            { opacity: 0 }
        ],
        { duration: t, easing: "ease-out", fill: "forwards"}
    );

    // Remove block from the DOM a little after the animation completes.
    setInterval(() => {
        block.remove();
    }, t+100);
}

...

document.addEventListener("keydown", function (event) {
    if(/^[a-z]/.test(event.key)){
        if (!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        if (blockCount < MAX_BLOCKS) {
            var newPos = choose(positions);

            while(newPos.isOccupied) {
                newPos = choose(positions);
            }

            createBlock(newPos);
            blockCount++;
            lastPos = newPos;
        } else {
            Array.from(document.getElementsByClassName("block")).forEach((el) => {
                removeBlock(el); // call new remove function
            });
            blockCount = 0;
            lastWord = choose(WORDS, lastWord);
            positions = precompute(ALL_POSITIONS, lastWord);
        }
    }
});

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares, each bearing a question mark, appear one at a time positioned randomly within a 3-by-3 configuration at center. As each square appears, it is briefly a solid color before fading to a colored outline. After 7 squares appear, they illuminate white and fade out. They are then replaced by a new configuration of squares that spells "BLOCKED".

They don't act simultaneously on removal due to for-loop timing differences, but I think I'll treat it as a happy accident that adds a small bit of visual interest.

For some extra flair, we can add a background animation that appears on cycle completion then eases out over a couple of seconds. We'll absolutely position the background element with some mild stretching. It will start transparent and flash opaque on cycle complete. It'll also be z-indexed negatively to keep it properly in the background.

Our particular background is an animation I've created before now converted to a GIF. Why GIF? Two reasons: browser performance and dithering. While we do have WEBP for animations, I've found that their framerate occasionally slows to a crawl; no doubt it's happened in this very writing. How browsers process WEBP data is still relatively new, but GIF processing has been around for decades and is much more stable. Plus for the digital etherealism that I enjoy, the dithering from GIF compression adds a lovely bit of texture.

...

const BLOCKS_BG = document.createElement("div");
BLOCKS_BG.id = "blocks-bg";
STAGE.appendChild(BLOCKS_BG);

...

function playBackground() {
    BLOCKS_BG.style.opacity = 1;
    BLOCKS_BG.animate(
        [
            { opacity: 1 },
            { opacity: 0 }
        ],
        { duration: 2000, easing: "ease-out", fill: "forwards" }
    )
}

...

document.addEventListener("keydown", function (event) {
    if(/^[a-z]/.test(event.key)){
        if (!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        if (blockCount < MAX_BLOCKS) {
            var newPos = choose(positions);

            while(newPos.isOccupied) {
                newPos = choose(positions);
            }

            createBlock(newPos);
            blockCount++;
            lastPos = newPos;
        } else {
            Array.from(document.getElementsByClassName("block")).forEach((el) => {
                removeBlock(el);
            });
            playBackground(); // on cycle complete
            blockCount = 0;
            lastWord = choose(WORDS, lastWord);
            positions = precompute(ALL_POSITIONS, lastWord);
        }
    }
});

#stage {
    ...

    #blocks-bg {
        position: absolute;
        height: 100vh;
        width: 100vw;
        opacity: 0;
        background-image: url("https://cdn.eriksonarias.dev/eriksonarias_dev/img/sky.gif");
        background-size: 200%;
        background-repeat: no-repeat;
        background-position: center;
        z-index: -1;
    }

    ...
}

An animation of a webpage. After a moment, all other visual elements fade out except the navigation and footer. Seven multicolored squares, each bearing an exclamation point, appear one at a time positioned randomly within a 3-by-3 configuration at center. As each square appears, it is briefly a solid color before fading to a colored outline. After 7 squares appear, they illuminate white and fade out, and the background briefly flashes with a slowly turning blue sky before fading out as well. They are then replaced by a new configuration of squares, this time each with a question mark.

Sound

I think at this point the visuals and interactivity are in a great spot, but just a bit of audio would push this even further. We'll use:

I already have two sounds in mind. For the first, there's a percussion sound used in Totaka's Song from Luigi's Mansion that starts with a crisp bite and a gooey, elastic release. It's one of my favorite sounds period. The second, a few slices from one of my favorite soundscapes patchtalk, pitched down three semitones with some extra reverb on the tail to make it feel weighty and vast.

To start, we'll create some Audio constants and a couple of helper methods:

...

const MUSIC1 = new Audio("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/patchtalk-1.mp3");
const MUSIC2 = new Audio("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/patchtalk-2.mp3");
const MUSIC3 = new Audio("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/patchtalk-3.mp3");
const MUSIC4 = new Audio("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/patchtalk-4.mp3");
const MUSIC = [MUSIC1, MUSIC2, MUSIC3, MUSIC4]; // we'll rotate through these

...

// We use this function for the block create sound.
function play(url) {
    new Audio(url).play();
}


// We use this function for the cycle complete sound.
var musicIdx = 0;
function playBackgroundSound() {
    var audio = MUSIC[musicIdx];
    MUSIC.forEach((m) => {
        if (!m.paused) {
            m.volume = m.volume / 2;
        }
    });
    audio.volume = 1;
    audio.pause();
    audio.currentTime = 0;
    audio.play();
    musicIdx = (musicIdx + 1) % MUSIC.length;
}

...

So we need two separate audio helper functions for a couple of reasons:

We can then layer these new functions into our existing functions:

...

function createBlock(position) {
    console.log(position);
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    var color = choose(COLORS);
    el.style.backgroundColor = color;
    el.style.border = `3px solid ${color}`;
    el.style.transition = "transition: all 0.25s ease";

    el.innerText = position.letter.toUpperCase();
    el.style.fontSize = "55px";
    el.style.color = "white";

    position.isOccupied = true;

    play("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/kiss.wav"); // on block create
    STAGE.appendChild(el);

    setInterval(function () {
        el.style.backgroundColor = null;
        el.style.color = color;
    }, 200);
}

...

function playBackground() {
    playBackgroundSound(); // we attach the sound to the cycle complete function
    play("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/kiss.wav"); // we'll also play the block create sound for continuity
    BLOCKS_BG.style.opacity = 1;
    BLOCKS_BG.animate(
        [
            { opacity: 1 },
            { opacity: 0 }
        ],
        { duration: 2000, easing: "ease-out", fill: "forwards" }
    )
}

...

Gorgeous.

Some shake

We can add just a tiny bit of movement by shaking the blocks container with each new block. We can leverage CSS transform again, though if we want to keep the grid positioning transforms intact, we'll need to wrap the blocks in a parent container and apply the new movement on the parent.

...

const BLOCKS_CONTAINER = document.createElement("div");
BLOCKS_CONTAINER.id = "blocks";
STAGE.appendChild(BLOCKS_CONTAINER);

...

function createBlock(position) {
    var el = document.createElement("div");
    el.classList.add("block");
    el.style.width = `${BLOCK_SIZE}px`;
    el.style.height = `${BLOCK_SIZE}px`;
    el.style.transform = `translate(${position.x}px, ${position.y}px)`;
    var color = choose(COLORS);
    el.style.backgroundColor = color;
    el.style.border = `3px solid ${color}`;
    el.style.transition = "background-color 0.25s ease, color 0.25s ease";
    el.innerText = position.letter.toUpperCase();
    el.style.fontSize = "55px";
    el.style.color = "white";

    position.isOccupied = true;
    play("https://cdn.eriksonarias.dev/eriksonarias_dev/sound/kiss.wav");
    BLOCKS_CONTAINER.style.transform = `translate(${randInt(16)-8}px, ${randInt(16)-8}px)`; // shake by 8px in any direction
    BLOCKS_CONTAINER.appendChild(el);

    setInterval(function () {
        el.style.backgroundColor = null;
        el.style.color = color;
    }, 200);
}

...

#stage {
    ...

    #blocks {
        display: flex;
        justify-content: center;
        align-items: center;
        transition: 0.05s all ease;
    }

    ...
}

Timing

Now for the final addition, a bit of a nuanced feature, we'll make block creation evenly timed when a key is held to create a simple straight rhythm.

If we were to hold a key down now, the first block would appear, followed by a long pause, then followed by a rapid stream of blocks. How the keyboard behaves on key hold is at the discretion of the operating system; we can't rely on the operating system to keep time for us when a key is held. At the same time, we can't reliably intercept the rapid-fire key events such that we can keep consistent timing.

So we revisit the concept of modes again. While we can't consistently manage each key event on hold, we can intercept the keydown and keyup events, using each to swap into play and pause modes.

To do this somewhat gracefully, we'll need to shift some code around, namely by creating a function that handles all the secondary function calls to create blocks and perform cycle resets. Then we'll call this function regularly using setInterval() when we know a key is being held; when the key is released, the interval is cleared and the cycle stops.

...

var isKeyDown = false; // key held flag
var interval = null; // interval for block creation and reset timings, needs to be passed to keyup event listener for clearing

...

// Cycle reset function
function nextBlock() {
    if(blockCount < MAX_BLOCKS) {
        interval
        var newPos = choose(positions);

        while (newPos.isOccupied) {
            newPos = choose(positions);
        }

        createBlock(newPos);
        blockCount++;
        lastPos = newPos;
    } else {
        playBackground();
        Array.from(document.getElementsByClassName("block")).forEach((el) => {
            removeBlock(el);
        });
        blockCount = 0;
        lastWord = choose(WORDS, lastWord)
        positions = precompute(ALL_POSITIONS, lastWord);
    }
}


document.addEventListener("keydown", function(event) {
    if(/^[a-z]/.test(event.key)){
        if (!isActive) {
            document.getElementById("hero").style.opacity = 0;
            document.getElementById("hero").style.userSelect = "none";
            isActive = true;
        }

        // check if key is held
        if(!isKeyDown) {
            isKeyDown = true; // signal that key is held
            nextBlock(); // immediately step through cycle on key held
            interval = setInterval(() => { // step through cycle every 100ms
                nextBlock();
            }, 100);
        }
    }
});

// On key release, signal that key is no longer held and clear block creation interval
document.addEventListener("keyup", () => {
    isKeyDown = false;
    clearInterval(interval);
});

The result

Conclusion

In the end, this small project took two days to realize from start to finish. There were a lot of quirks to learn, challenges to navigate, and expectations to reconsider as this piece learned to walk. But once I found out what was possible, it quickly learned to run. It was all a process of discovering what this thing was going to be in the end.

I was inspired to create, and though bounded by constraints it was freed from expectations. There was no reality in which this experiment could disappoint; it could only surprise.

Maybe I'll keep iterating on this idea, adding guardrails and fine carpentry, but I think it's more than sufficient to let out into the wider world, and hopefully others will be as delighted in experiencing it was I was in making it.

Finished code

The finished code is available through Forgejo:

Tags: art software-engineering

Previously

Out of the rack

Next

We don't sell code