Updated September 27 2025: I finally turned this effect off by default. It's still there, but
you may have to go on a little scavenger hunt in the developer console to activate it again...
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:
- A gentle "bobbing" animation, which is actually made up of two simultaneous CSS animations
- A very light scaling effect on mouseover, combined with a drop shadow to make it look like the
card "lifts up" off the page
- A pair of 3D rotations that use CSS custom properties and a bit of JavaScript to produce the "card
tilt" effect on mouseover
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?
animation-duration
specifies how long the animation will play for. For example, if I specify
5s
as the animation-duration
, then the 50%
mark of the @keyframes
data will correspond to
two and a half seconds, and the 100%
mark will correspond to five seconds.
animation-timing-function
specifies how the animation moves from 0%
to 100%
. For example, a
linear
animation will be completely consistent all the way through; an ease-in-out
animation
will be slow at the start and end, but fast in the middle.
animation-delay
specifies the delay after which the animation will start playing. I believe this
starts from the moment an element's style obtains an animation
, but I'm not positive about this.
animation-name
specifies which @keyframes
data the animation will use.
animation-iteration-count
specifies how many times the animation will play. To loop it forever,
you can specify infinite
.
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:
- Subtract the mouse position relative to the tile's top-left corner (
e.offsetX
) from half the
tile width. This gives us a value ranging from -halfTileWidth
to +halfTileWidth
, since the
mouse position will be between 0
and tileWidth
.
- Divide that value by
halfTileWidth
. This gives us a value between -1.0
and +1.0
,
corresponding to the left and right edges of the tile, respectively. Where the value lies in this
range tells us where on the card, horizontally, the mouse cursor is. 0.0
is the dead center.
- Multiply this value by
HORIZONTAL_TILT_AMOUNT
, which is the maximum amount of rotation (in
degrees) we want to tilt by, e.g. if the mouse is at the very extreme edge of the tile. Because
0.0
is the middle of the card, this will give us no rotation if the mouse is in the middle,
positive rotation if we're on the right, and negative rotation if we're on the left.
- Negate this value, because we actually want to rotate away from the mouse cursor, not towards
it.
- Add the CSS
deg
unit to the property value, and write it to the element's style
.
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... :)
Update: Card Shine
I played with the effect a bit more and noticed something: you can't really tell the difference
between an "up" rotation and a "down" rotation. I realized Balatro has a "shine" effect on certain
cards that helps emphasize the tilt effect, which I decided to try and replicate. I'm pretty pleased
with what I came up with. You should see it above too, but here's another example:
Calculating The Shine Angle
We're going to build the shine effect out of a linear gradient, which takes an angle and a list of
color bands. The angle is the first piece of that puzzle.
So, how do we get from the information we have (the mouse position) to an angle? We can start by
converting the X and Y displacement into an angle using Math.atan2
, which gives us an angle
between -π and π. Then, we can map this into degrees by multiplying by 180 / Math.PI
, negate it to
make it oppose the mouse offset instead of following it, and finally add another 180 degrees to put
the angle between 0 and 360 degrees:
const shineAngle = -Math.atan2(relativeX, relativeY) * 180 / Math.PI + 180;
tile.style.setProperty('--shine-angle', `${shineAngle}deg`);
And finally set it in our stylesheet:
.tiles li:hover {
background: linear-gradient(var(--shine-angle), white, #e8e8e8);
}
Calculating The Shine Offset
What we have now creates a shine effect that orbits the center of the tile. It looks okay, but it
can look a bit better: we can make the shine band move across the card depending on how deep we're
tilting it. Our strategy for doing this is to find the maximum of the horizontalAlpha
and
verticalAlpha
we computed before, multiply it by 100 to get a percentage, and then compute a
secondary band by subtracting 10%:
const shineAlphaX = Math.abs(100 * horizontalAlpha);
const shineAlphaY = Math.abs(100 * verticalAlpha);
const shineAlphaMax = Math.max(shineAlphaX, shineAlphaY);
const shineAlphaMin = Math.max(0, shineAlphaMax - 10);
tile.style.setProperty('--shine-alpha', `${shineAlphaMin}% ${shineAlphaMax}%`);
And set that into the stylesheet as well:
.tiles li {
background: linear-gradient(var(--shine-angle), white, #e8e8e8 var(--shine-alpha));
}
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 = 20;
const VERTICAL_TILT_AMOUNT = 20;
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 relativeX = e.pageX - (tile.offsetLeft + halfTileWidth);
const relativeY = e.pageY - (tile.offsetTop + halfTileHeight);
const horizontalAlpha = relativeX / halfTileWidth;
const horizontalTilt = `${-horizontalAlpha * HORIZONTAL_TILT_AMOUNT}deg`;
tile.style.setProperty('--horizontal-tilt', horizontalTilt);
const verticalAlpha = relativeY / halfTileHeight;
const verticalTilt = `${verticalAlpha * VERTICAL_TILT_AMOUNT}deg`;
tile.style.setProperty('--vertical-tilt', verticalTilt);
const shineAngle = -Math.atan2(relativeX, relativeY) * 180 / Math.PI + 180;
tile.style.setProperty('--shine-angle', `${shineAngle}deg`);
const shineAlphaX = Math.abs(100 * horizontalAlpha);
const shineAlphaY = Math.abs(100 * verticalAlpha);
const shineAlphaMax = Math.max(shineAlphaX, shineAlphaY);
const shineAlphaMin = Math.max(0, shineAlphaMax - 10);
tile.style.setProperty('--shine-alpha', `${shineAlphaMin}% ${shineAlphaMax}%`);
});
tile.addEventListener('mouseout', () => {
tile.style.removeProperty('--horizontal-tilt');
tile.style.removeProperty('--vertical-tilt');
tile.style.removeProperty('--shine-angle');
tile.style.removeProperty('--shine-alpha');
});
}
});
@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 {
border: 2px solid var(--link-color);
border-radius: 4px;
color: black;
list-style-type: none;
margin: 0;
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 {
background: linear-gradient(var(--shine-angle), white, #e8e8e8 var(--shine-alpha));
border: 2px solid var(--link-color-hover);
box-shadow: lightgrey 4px 4px;
color: var(--link-color-hover);
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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
**Updated September 27 2025:** I finally turned this effect off by default. It's still there, but
you may have to go on a little scavenger hunt in the developer console to activate it again...
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](https://cohost.org), 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](https://www.playbalatro.com/).
## 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):
<ul class="tiles">
<li>
<a href="#">
<h3>Post Title</h3>
<p>Post summary</p>
</a>
</li>
<li>
<a href="#">
<h3>Another Post</h3>
<p>Some neat stuff</p>
</a>
</li>
</ul>
This effect is a combination of the following elements:
- A gentle "bobbing" animation, which is actually made up of two simultaneous CSS animations
- A very light scaling effect on mouseover, combined with a drop shadow to make it look like the
card "lifts up" off the page
- A pair of 3D rotations that use CSS custom properties and a bit of JavaScript to produce the "card
tilt" effect on mouseover
## 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?
- `animation-duration` specifies how long the animation will play for. For example, if I specify
`5s` as the `animation-duration`, then the `50%` mark of the `@keyframes` data will correspond to
two and a half seconds, and the `100%` mark will correspond to five seconds.
- `animation-timing-function` specifies how the animation moves from `0%` to `100%`. For example, a
`linear` animation will be completely consistent all the way through; an `ease-in-out` animation
will be slow at the start and end, but fast in the middle.
- `animation-delay` specifies the delay after which the animation will start playing. I believe this
starts from the moment an element's style obtains an `animation`, but I'm not positive about this.
- `animation-name` specifies which `@keyframes` data the animation will use.
- `animation-iteration-count` specifies how many times the animation will play. To loop it forever,
you can specify `infinite`.
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:
- Subtract the mouse position relative to the tile's top-left corner (`e.offsetX`) from half the
tile width. This gives us a value ranging from `-halfTileWidth` to `+halfTileWidth`, since the
mouse position will be between `0` and `tileWidth`.
- Divide that value by `halfTileWidth`. This gives us a value between `-1.0` and `+1.0`,
corresponding to the left and right edges of the tile, respectively. Where the value lies in this
range tells us where on the card, horizontally, the mouse cursor is. `0.0` is the dead center.
- Multiply this value by `HORIZONTAL_TILT_AMOUNT`, which is the maximum amount of rotation (in
degrees) we want to tilt by, e.g. if the mouse is at the very extreme edge of the tile. Because
`0.0` is the middle of the card, this will give us no rotation if the mouse is in the middle,
positive rotation if we're on the right, and negative rotation if we're on the left.
- Negate this value, because we actually want to rotate _away_ from the mouse cursor, not towards
it.
- Add the CSS `deg` unit to the property value, and write it to the element's `style`.
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... :)
## Update: Card Shine
I played with the effect a bit more and noticed something: you can't really tell the difference
between an "up" rotation and a "down" rotation. I realized Balatro has a "shine" effect on certain
cards that helps emphasize the tilt effect, which I decided to try and replicate. I'm pretty pleased
with what I came up with. You should see it above too, but here's another example:
<ul class="tiles">
<li>
<a href="#">
<h3>Shiny!</h3>
<p>Wow! I'm shiny!</p>
</a>
</li>
</ul>
### Calculating The Shine Angle
We're going to build the shine effect out of a linear gradient, which takes an angle and a list of
color bands. The angle is the first piece of that puzzle.
So, how do we get from the information we have (the mouse position) to an angle? We can start by
converting the X and Y displacement into an angle using `Math.atan2`, which gives us an angle
between -π and π. Then, we can map this into degrees by multiplying by `180 / Math.PI`, negate it to
make it oppose the mouse offset instead of following it, and finally add another 180 degrees to put
the angle between 0 and 360 degrees:
```
const shineAngle = -Math.atan2(relativeX, relativeY) * 180 / Math.PI + 180;
tile.style.setProperty('--shine-angle', `${shineAngle}deg`);
```
And finally set it in our stylesheet:
```
.tiles li:hover {
background: linear-gradient(var(--shine-angle), white, #e8e8e8);
}
```
### Calculating The Shine Offset
What we have now creates a shine effect that orbits the center of the tile. It looks okay, but it
can look a bit better: we can make the shine band move across the card depending on how deep we're
tilting it. Our strategy for doing this is to find the maximum of the `horizontalAlpha` and
`verticalAlpha` we computed before, multiply it by 100 to get a percentage, and then compute a
secondary band by subtracting 10%:
```
const shineAlphaX = Math.abs(100 * horizontalAlpha);
const shineAlphaY = Math.abs(100 * verticalAlpha);
const shineAlphaMax = Math.max(shineAlphaX, shineAlphaY);
const shineAlphaMin = Math.max(0, shineAlphaMax - 10);
tile.style.setProperty('--shine-alpha', `${shineAlphaMin}% ${shineAlphaMax}%`);
```
And set that into the stylesheet as well:
```
.tiles li {
background: linear-gradient(var(--shine-angle), white, #e8e8e8 var(--shine-alpha));
}
```
## 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 = 20;
const VERTICAL_TILT_AMOUNT = 20;
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 relativeX = e.pageX - (tile.offsetLeft + halfTileWidth);
const relativeY = e.pageY - (tile.offsetTop + halfTileHeight);
const horizontalAlpha = relativeX / halfTileWidth;
const horizontalTilt = `${-horizontalAlpha * HORIZONTAL_TILT_AMOUNT}deg`;
tile.style.setProperty('--horizontal-tilt', horizontalTilt);
const verticalAlpha = relativeY / halfTileHeight;
const verticalTilt = `${verticalAlpha * VERTICAL_TILT_AMOUNT}deg`;
tile.style.setProperty('--vertical-tilt', verticalTilt);
const shineAngle = -Math.atan2(relativeX, relativeY) * 180 / Math.PI + 180;
tile.style.setProperty('--shine-angle', `${shineAngle}deg`);
const shineAlphaX = Math.abs(100 * horizontalAlpha);
const shineAlphaY = Math.abs(100 * verticalAlpha);
const shineAlphaMax = Math.max(shineAlphaX, shineAlphaY);
const shineAlphaMin = Math.max(0, shineAlphaMax - 10);
tile.style.setProperty('--shine-alpha', `${shineAlphaMin}% ${shineAlphaMax}%`);
});
tile.addEventListener('mouseout', () => {
tile.style.removeProperty('--horizontal-tilt');
tile.style.removeProperty('--vertical-tilt');
tile.style.removeProperty('--shine-angle');
tile.style.removeProperty('--shine-alpha');
});
}
});
```
```
@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 {
border: 2px solid var(--link-color);
border-radius: 4px;
color: black;
list-style-type: none;
margin: 0;
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 {
background: linear-gradient(var(--shine-angle), white, #e8e8e8 var(--shine-alpha));
border: 2px solid var(--link-color-hover);
box-shadow: lightgrey 4px 4px;
color: var(--link-color-hover);
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;
}
}
```
<script src="/static/scripts/tile-tilt.js"></script>
<script>balatrofy()</script>