I Balatrofied My Posts

Published on • Updated on

Hello! This is a silly post about a silly topic. I just wanted to let you know that you can turn off the moving elements in this post (and across my whole website) by setting the Reduced Motion option on your operating system or browser.

CSS Crimes

Up until October of 2024 (when they went read-only in preparation to cease operations), I had made my internet home on Cohost, a small, cozy social media site with a culture of "CSS Crimes". See, their post rendering engine allowed you to write HTML, and importantly, to use the style="..." attribute on nearly any HTML element to customize how your posts looked to a degree that no other posting site has allowed since the dawn of the internet. It led to a really cool culture of making and sharing toys, games, animations, and shitposts with HTML and CSS, and it's one of the things I miss most about that site.

The silver lining about moving my web presence to my own site is that I am no longer limited to mere CSS crimes. I have access to JavaScript Sins now. I can write backend code to further enable my depravity. The shackles of the HTML and CSS allowlists have been shattered. I can do whatever I want.

I can make my post archives look like Balatro.

A Quick Demo

Since this effect is entirely in-browser, I can show you how it works right in this page (although if you're on a phone, sorry, the card tilt effect won't work):

This effect is a combination of the following elements:

Card Bobbing

This is a really simple effect, but it's a little annoying to wrangle because CSS animations have varying levels of browser support at the time of writing. The basic components are as follows:

The @keyframes Animation Data

@keyframes declarations are how you specify what a CSS animation does. It allows you to update the animated object's CSS properties at various points through the animation, specified in percentages (of the complete animation timeline). Here's the two @keyframes declarations for the card bob:

@keyframes tile-bob {
	0% {
		translate: 0px 0px;
	}

	50% {
		translate: 0px -10px;
	}

	100% {
		translate: 0px 0px;
	}
}

@keyframes tile-wiggle {
	0% {
		rotate: 0;
	}

	25% {
		rotate: -1deg;
	}

	50% {
		rotate: 0;
	}

	75% {
		rotate: 1deg;
	}

	100% {
		rotate: 0;
	}
}

The animation-* Properties

The @keyframes data doesn't do anything by itself; we need to actually add animations to an element to see them in action. That's done with the animation-* properties, which (mostly) have a shorthand in the animation property. I found that things are most likely to work if I specify animation-duration, animation-timing-function, animation-delay, and animation-name in the animation property (in that order), and then specify the animation-iteration-count separately. I didn't mess with the other animation-* properties, but my process was pretty much to just try using the animation shorthand and then move things to the individual properties if it wasn't working.

So, what do all of these properties do?

Something else to note is that every animation-* property can accept any amount of animations that all play simultaneously; you just separate the property values with commas.

Here is how I used these properties with the @keyframes data above to create the card bob effect:

.tiles li {
	animation:
		10s ease-in-out var(--tile-animation-offset) tile-bob,
		7s ease-in-out var(--tile-animation-offset) tile-wiggle;
	animation-iteration-count:
		infinite,
		infinite;
}

You'll notice I specified the delay as var(--tile-animation-offset), which offsets the animation of each tile by that amount so they aren't all the same, but I didn't define that property anywhere. This is what we'll talk about next.

CSS Custom Properties

A while ago, CSS added the ability to define and then query "custom properties", which are more or less overrideable constants within the context of pure CSS. You typically define them on :root to keep things like colors, margins, sizes, etc. consistent across your site, by querying these properties using var(--the-property-name) instead of copying the same values all over your stylesheet.

These properties are overrideable in the sense that they cascade like a normal property: if you have a custom property defined on :root, but then redefine the same property on article elements, then anything inside an <article> tag will use the overridden definition, not the default one.

The really cool part about custom properties is that they're live: if an element receives a new override of a custom property, e.g. through JavaScript, the element will be re-rendered with the new value. This is exactly how we use them: both to supply the --tile-animation-offset property for the previous section, and to supply two more properties, --horizontal-tilt and --vertical-tilt, for the next section.

To set a custom property on an element from JavaScript, assuming you have the element in a variable someElement, you can use this code:

someElement.style.setProperty('--my-custom-property', 'property-value');

Card Tilt

Transforming Mouse Coordinates Into Rotations

The card tilt effect is made up of two 3D rotations: one about the Y axis (which goes from the top of the screen to the bottom of the screen), and one about the X axis (which goes from the left side of the screen to the right side of the screen). To find out where we want to tilt, we need to compare the mouse pointer's position to the tile's center, but only when the mouse is actually over the tile. For this, I use the mousemove and mouseout events, which trigger when the mouse moves over and moves off of the element, respectively:

const halfTileWidth = tile.offsetWidth / 2;
const halfTileHeight = tile.offsetHeight / 2;

tile.addEventListener('mousemove', (e) => {
	const horizontalAlpha = (e.offsetX - halfTileWidth) / halfTileWidth;
	const horizontalTilt = `${-horizontalAlpha * HORIZONTAL_TILT_AMOUNT}deg`;
	tile.style.setProperty('--horizontal-tilt', horizontalTilt);

	const verticalAlpha = (e.offsetY - halfTileHeight) / halfTileHeight;
	const verticalTilt = `${verticalAlpha * VERTICAL_TILT_AMOUNT}deg`;
	tile.style.setProperty('--vertical-tilt', verticalTilt);
});

tile.addEventListener('mouseout', () => {
	tile.style.removeProperty('--horizontal-tilt');
	tile.style.removeProperty('--vertical-tilt');
});

Taking just the horizontal tilt as an example, the process is as follows:

The vertical tilt calculation is identical, except we don't negate it because the mouse's coordinate system has its Y axis pointing down, which means a "negative" rotation is up, which is what we wanted anyway. It's just a quirk of how the coordinate system interacts with rotations.

Applying The Rotations In CSS

This part is easy. We just use the transform property when the element is hovered over (combined with a small scale(1.05) to make the card pop up a little bit, as mentioned earlier):

.tiles li:hover {
	box-shadow: lightgrey 4px 4px;
	transform: scale(1.05) rotate3d(0, 1, 0, var(--horizontal-tilt)) rotate3d(1, 0, 0, var(--vertical-tilt));
	animation: none;
}

This is also where we set the box-shadow, which creates a nice drop shadow that follows the element's frame to sell the "pop up" effect a little more, and disable all current animations so that the tile isn't bobbing and weaving while you're trying to click on it.

Reduced Motion

Whenever you play with decorative animations, it's a good idea to provide a way to turn them off. You can use a button or toggle for this if it's appropriate, but I opted to use the prefers-reduced-motion CSS media query to disable anything that makes the tiles move:

@media (prefers-reduced-motion) {
	.tiles li {
		animation: none;
	}

	.tiles li:hover {
		transform: none;
		animation: none;
	}
}

Should I Keep It?

This is a fun little effect, but it's only fun until it's annoying. I'll probably keep it around for a few weeks, then hide it behind an easter egg. Speaking of which, see what else you can find buried on this site... :)

Addendum: Full Source Code

Here is the full tile-tilt.js script I wrote, and also the relevant parts of my stylesheet:

document.addEventListener('DOMContentLoaded', () => {
	const TILE_SELECTOR = '.tiles li';
	const HORIZONTAL_TILT_AMOUNT = 10;
	const VERTICAL_TILT_AMOUNT = 15;

	const tiles = document.querySelectorAll(TILE_SELECTOR);
	let tileIndex = 0;

	for (const tile of tiles) {
		tile.style.setProperty('--tile-animation-offset', `${tileIndex}s`);
		++tileIndex;

		const halfTileWidth = tile.offsetWidth / 2;
		const halfTileHeight = tile.offsetHeight / 2;

		tile.addEventListener('mousemove', (e) => {
			const horizontalAlpha = (e.offsetX - halfTileWidth) / halfTileWidth;
			const horizontalTilt = `${-horizontalAlpha * HORIZONTAL_TILT_AMOUNT}deg`;
			tile.style.setProperty('--horizontal-tilt', horizontalTilt);

			const verticalAlpha = (e.offsetY - halfTileHeight) / halfTileHeight;
			const verticalTilt = `${verticalAlpha * VERTICAL_TILT_AMOUNT}deg`;
			tile.style.setProperty('--vertical-tilt', verticalTilt);
		});

		tile.addEventListener('mouseout', () => {
			tile.style.removeProperty('--horizontal-tilt');
			tile.style.removeProperty('--vertical-tilt');
		});
	}
});
@keyframes tile-bob {
	0% {
		translate: 0px 0px;
	}

	50% {
		translate: 0px -10px;
	}

	100% {
		translate: 0px 0px;
	}
}

@keyframes tile-wiggle {
	0% {
		rotate: 0;
	}

	25% {
		rotate: -1deg;
	}

	50% {
		rotate: 0;
	}

	75% {
		rotate: 1deg;
	}

	100% {
		rotate: 0;
	}
}

.tiles li {
	transition: all 0.1s;
	animation:
		10s ease-in-out var(--tile-animation-offset) tile-bob,
		7s ease-in-out var(--tile-animation-offset) tile-wiggle;
	animation-iteration-count:
		infinite,
		infinite;
}

@media (hover: hover) {
	.tiles li:hover {
		box-shadow: lightgrey 4px 4px;
		transform: scale(1.05) rotate3d(0, 1, 0, var(--horizontal-tilt)) rotate3d(1, 0, 0, var(--vertical-tilt));
		animation: none;
	}
}

@media (prefers-reduced-motion) {
	.tiles li {
		animation: none;
	}
}

@media (prefers-reduced-motion) and (hover: hover) {
	.tiles li:hover {
		transform: none;
		animation: none;
	}
}