A CSS-only, no JS, no checkbox, position-aware (hover/touch aware) interactive gecko-luring pastime!
Β
TL;DR I revisited an old CSS-only hack with new CSS features and some ingenuity to bypass rendering limitations and achieve as much as a 25.6x precision gain over the best previous solution β getting much closer to native position awareness! Try out the mini-game, play with just the βdebounced CSS algorithm,β and if you are in a rush, start reading from Section 2.
Intro
We're gathered here today to gain awareness about an ingenious CSS trick I call CSS-only Position Awareness. We could also call it Position-Aware CSS, but it fails to unambiguously state that no JavaScript was used.
It's based on a known trick, but, as you'll soon find out, CSS :has
enabled us to push it further. And so we did. Besides, AI still has no clue about it and maybe neither do you. Here's what ChatGPT had to say this morning β March 27th, 2024:
This article will be interactive and hopefully inspiring. So, stick around! (Or at least scroll to see the cute photos)
Table of Contents
1. The idea
Back in September 2023, I was custom styling a <table>
component and ran into a fun use case for the :has()
pseudo-class. Without resorting to a "CSS-grid table," but using the actual β correct and accessible β <table>
markup, I could now highlight not only the row but also the column being hovered!
See it in action:
π‘: [ Fun fact ] Sten Hougaard discovered this same trick a month later and got featured on CodePen's Instagram. π
What stood out was how little CSS I needed to write. All it took was 37 lines of pure CSS to highlight a 7-column table by any given number of rows. What if I needed more columns? Could I refactor this CSS algorithm to support any number of columns? While digging for this answer I stumbled upon something else, a better algorithm for CSS-only Position Awareness. And that's where the game comes in.
Before we get all scientific, let's play the mini-game!
Β
Β
π: Supported on all devices and modern browsers. For the best mouse-tracking UX try Chrome Desktop (or Edge Desktop)
This was all achieved without a single line of JavaScript! You're welcome to double-check and disable JavaScript on your browser.
2. Position Awareness
A code is position-aware when we can pinpoint the --x
and --y
coordinates of a cursor on the screen and make these values available for any element on the page. To achieve this without JavaScript requires a position grid to detect which subsection of the screen is being hovered. Ideally, we would have a position cell per pixel, but we aren't there yet. We might, however, be just a Moore's Law cycle away.
Previous iterations of the Position Grid were modest, only tracking 4 quadrants and calling it Direction Awareness. It evolved into 10x10 grids (100 cells) called Mouse Tracking, some as large as 16x16. However, Mouse Tracking was not the best name for something that should include track pads and touch devices. Now it evolves once more, with the addition of :has
and dynamic rendering. I present to you the Position Awareness grid, with 81x81, 6561 cells!
There's a catch to Position Awareness: we change the expected behavior of hovering on a page. Instead of tracking :hover
interactions with DOM elements, we are tracking interactions with subsections of the screen.
Here's a βdebouncedβ Position-Aware CSS algorithm in action:
π±οΈπ» : Intended for mouse and trackpad devices.
π: 81x81 grid for Desktop, 27x27 grid mobile
Β
2.a.: Precision Gains
Previous approach was achieved by placing non-nested cells before a target element, and transferring placement coordinates through the βany sibling aheadβ selector .cell:hover ~ .position-aware-element
. This overloads the wrapper component with a huge number of child nodes and can impact performance.
Here's a brute-force image generated by painting each pixel with CSS to illustrate what happens when we style too many elements. Hit play at your peril π±.
Current approach takes advantage of the :has()
pseudo-class which supports nested position cells. By nesting a single level deep we reduce the max number N of children cells to the square root of N. E.g.: a previous shallow position grid with 100 children cells can now be represented with 10 children, each with 10 children. This way, every single cell has only at most 10 children. Imagine if we nest even deeper! This two-level approach hits a performance cap at around 33x33 (1089 points of awareness). This is the resolution I'm using for Curious Geckos. Beyond that limit I would need to create dynamically rendered grid subsections, like I showcased on the blue Position-Aware CSS example above. This is similar to how 3D game engines dynamically increase the level of detail on elements near the camera.
π: More on this in a future article
3. Building a Position-Aware Game
Now that we have a Position-Aware Grid filled with (x, y)
coordinates, how do we go from --x
and --y
values to a working mini-game?
Here's the trimmed version of the steps:
3.1.: Feed --x
and --y
coordinates into the πͺ°(fly) and π¦(gecko) wrappers
@for $i from 1 through 33 {
.position-aware-container:has(tr:nth-child(#{$i}):hover) {
:is(.fly__wrapper, .gecko__placement) {
--y: #{($i - 1) * $axis-step};
}
}
.position-aware-container:has(td:nth-child(#{$i}):hover) {
:is(.fly__wrapper, .gecko__placement) {
--x: #{($i - 1) * $axis-step};
}
}
}
3.2.: Use CSS calc
to rotate the πͺ°(fly) by a centrifugal angle
3.3.: Create SVG puppets for the geckos:
Β· 3.3.a.: A Puppet, also known as a Rig Model, is a technique used for animation. You might have seen it before on my portfolio
π: More on this in a future article
Β· 3.3.b.: Group each body part in a hierarchical structure. E.g.: If I rotate the shoulder, the entire arm rotates.
Β· 3.3.c.: Anchor geckos by their thorax to facilitate head angle calculation:
--head-angle: max(
var(--min-head-angle),
// limit CCW head rotation
min(
var(--max-head-angle),
// limit CW head rotation
calc(
(atan2(var(--dy), var(--dx)) * var(--head-angle-intensity, 1)) - calc(
(
var(--head-initial-rotation, 0) * var(
--head-angle-intensity,
1
) * 1deg
) + var(--head-angle-correction, 0deg)
)
)
)
);
Β· 3.3.d.: Split the head rotation in three for a more natural motion:
.gecko__neck__body {
--rotate-member: calc(var(--head-angle) * 1 / 6);
}
.gecko__neck__head {
--rotate-member: calc(var(--head-angle) * 1 / 3);
}
.gecko__head {
--rotate-member: calc(var(--head-angle) * 1 / 2);
}
3.4.: Set attack zones per gecko and per @media query.
3.5.: Detect attack zone :hover
and trigger attack state
// ----- Gecko 1 attacks ----- //
:is(
.gecko-trap-1_base,
.gecko-trap-1_min200,
.gecko-trap-1_min250,
.gecko-trap-1_min300,
.gecko-trap-1_min350,
.gecko-trap-1_min450,
.gecko-trap-1_max166,
.gecko-trap-1_max150,
.gecko-trap-1_max125,
.gecko-trap-1_max100,
.gecko-trap-1_max75,
.gecko-trap-1_max66,
.gecko-trap-1_max50
) {
body:has(& span:hover) {
- Expand the hovered cell to full-screen. This locks hover to the last hovered cell. Locking the attack state.
- Attack state freezes fly movement and animates the desired gecko.
3.6.: Slide in a pulsating call-to-action with a higher z-index
, this will trigger the restart state.
3.7.: Add some flourishes:
- tail animations
- fine-tuned gecko placements to best fit the aspect ratio
- Black and White gecko's resting arm changes position based on the @media query for best composition
3.8.: You're done!
4. Cast and Credits
- Geckos β SVGs designed in Figma β by warkentien2
- Scales by pattern.monster, thanks Naveen CS!
- Fly - CSS art β by warkentien2
- First sketch
5. Previous art (Post-project research)
Although many developers have tackled some form of Position Awareness, I would like to highlight a few:
- Fabrice Weinberg, June 2013: 2x2 (first ever?) Direction-Aware Grid (Code)
- Gabrielle Wee, Jan 2017: 4 cells. Beautiful direction-aware effect. (Code)
- Gabrielle Wee, April 2018: 10x10 grid with 100 individual
<a>
tags and 100 individual style definitions. (Code) > "It would be smoother with more links but also would take longer to load, so I only used 100 instead of something like 1000." > β Gabrielle Wee's notes on this Pen - Christopher Joshua, Nov 2019: 16x16 grid with 256 individual
<i>
tags and 768 lines of CSS just for tracking. (Code) - Honorable Mention (1) Jane Ori's amazing etch-a-sketch with an impressive 75x50 grid! (Code) Genius state tracking with the space toggle hack + animations. Β Β ______________________________________ Β Β (1): Honorable mention since it achieves Position Tracking but not Position Awareness. Β· 5.a.: Position information is dynamically set, not extracted. Β· 5.b.: Cell can't be hovered twice. Β· 5.c.: Regardless, it's impressive and deserves to be shared!
6. FAQ
- Is this really a game? Well, it's as much of a game as playing catch is a game. βΎ It's the reason I called it a mini-game βΎ or a pastime.
- Why make this? To Flex... and to Grid.
- What do you mean by βno checkboxβ?
- Most CSS-only games use checkbox for state management, but not this one.
- This game runs completely on
:hover
events, even to track victory/restart.
- UX is jittery, can I improve it?
Troubleshooting:
- Try Chrome Desktop or Edge Desktop for the best UX.
- If you're a MacBook user, you can emulate Chrome inside Safari:
Safari > Develop (tab) > Open Page With > Google Chrome
- If you're a MacBook user, you can emulate Chrome inside Safari:
- Use a JS disabler (extension).
- If it's still slow, use Firefox or Safari Desktop for a debounced hover tracking experience.
- Try Chrome Desktop or Edge Desktop for the best UX.
Β
Β
Thank you for reading!
You're welcome to ask questions and speak your mind.
Follow me on X, @warkentien2
Top comments (4)
Very, very cool!
Great work!!!
Cool~
Adorable!
*-*
Great work and interesting post! Thanks for mentioning of my pen at CodePen :-)