Let's grok that: Jhey anchor positioning hover cards
If you're not following Jhey on X, you really should be. He's constantly pushing the boundaries of what's possible with CSS and Javascript. I'll never understand how he can consistently push out these demos.
Given their complexity, it can be easy to get overwhelmed. Let's dissect one of these demos and break it down, line by line.
Here's a Codepen link demonstrating the anchor positioning API as well as a javascript fallback for similar behavior. We're only going to focus on the fallback because as of the time of writing, CSS Anchor Positioning is only available behind an experimental web platform features flag.
The HTML markup is straightforward:
<main>
<ul>
<li>
<article>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">...</svg>
<h3>Develop.</h3>
<p>
...
</p>
</article>
<li>
...
</ul>
</main>
CSS
Lines 1 through 40 are pretty standard. Loading an external font family, changing the default box sizing from content-box to border-box, setting a few css variables, setting the display context of the list as grid, etc.
Things start to get interesting on line 44.
ul::after {
border-radius: 1rem;
content: "";
position: absolute;
background: hsl(0 0% 10%);
pointer-events: none;
z-index: -2;
inset:
calc(var(--top) * 1px)
calc(100% - (var(--right) * 1px))
calc(100% - (var(--bottom) * 1px))
calc(var(--left) * 1px);
transition: inset 0.2s;
}
This is our hover element. There's a single box that's being created as a pseudo element on the entire list rather than each individual list item.
This hover element is then positioned using inset
. Like margin, inset has the same multi-value syntax of top right bottom left
. Each of those values
are being set with css variables. We aren't declaring those inside our css so they must be getting set inside our javascript. We will dig a little deeper into later on.
For now though, it's enough to know that we have a single psuedo element that's acting as our list item hover style, that its inset changes dynamically, and that has a transition
so that it moves to its new position smoothly.
Let's look at the next three rules.
ul[data-enhanced]:hover {
--active: 1;
}
ul[data-enhanced]::after {
opacity: var(--active, 0);
transition: opacity 0.2s, inset 0.2s 0.2s;
}
ul[data-enhanced]:hover::after {
transition: opacity 0.2s 0.2s, inset 0.2s;
}
I think these 3 lines are very clever. When our unordered list is being hovered over, a custom property called --active
is being set to a value of 1
. When the hover no longer applies,
this custom property is no longer defined. This affects our ::after
selector as the opacity is 1
when the list is hovered and the fallback value of 0
when --active
isn't defined. Finally, because
transitions applied to things like hover
effect only go 1 way, it has to be defined on both the ::after
and the :hover::after
to ensure the transition happens when entering and exiting the list.
We're going to ignore the next 35 lines for now. Line 70 is testing to see if the browser supports the anchor positioning API. Maybe in the future we will enable it in chrome canary and grok that but for now we're staying focused on how this demo is working for 99.9% of us.
That includes this line:
ul:not([data-enhanced]) article::after
Remember that when our javascript runs, if it determines that the anchor positioning API isn't support it appends the data-enhanced
attribute to our unordered list.
This selector is saying that when our unordered
list isn't enhanced, find deeply nested article
, and target that articles ::after
psuedo-element.'
The rest of the CSS is pretty standard but there is one more thing I want to focus on before getting into the javascript.
article::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(var(--bg) 0 2px, transparent 2px 38px) -20px -20px / 40px 40px,
linear-gradient(90deg, var(--bg) 0 2px, transparent 2px 38px) -20px -20px / 40px 40px;
mask: linear-gradient(-35deg, var(--bg) 0%, transparent 45%);
z-index: -1;
opacity: var(--li-active, 0);
transition: opacity .2s;
}
li:hover {
--li-active: 1;
}
When I first read this line I was a little confused. We already have a psuedo-element on the whole list that we're moving around and acting as our hover state.
Why are we doing the same thing on each individual article? The answer is so that additional styles can be achieved. These little details are a big part of what makes Jhey so great at what
he does. This ::before
element that gets its opacity set to 1
on hover is what's applying the grid background that each article has on hover. To make this really obvious, you can change
the transition property to 5 seconds.
Javascript
With that complete, let's get into the Javascript!
It's surprisingly short. First, it checks to see if the anchor point api is not support. As long as it's not, then we get into the rest of it.
const LIST = document.querySelector('ul')
LIST.dataset.enhanced = true
let current
const UPDATE = ({ x, y }) => {
...
}
LIST.addEventListener('pointermove', UPDATE)
Nothing too crazy going on here. A variable called LIST
is set to represent the unordered list. We use that variable to set the enhanced
attribute. Next is a variable called current
which is left undefined. Then a function called UPDATE
is created. This function accepts a x
and y
parameter. Finally, an event listener is created on our LIST
variables. It listens for the
pointermove
event and is given our UPDATE
function as a callback.
Let's dig into the UPDATE
function.
The first thing that happens is setting a const
declaration called ARTICLE
using an interesting DOM api elementFromPoint
. This api returns the topmost element at a specified x and y coordinate.
Because we're looking to get the current article, .closest('li')
is then chained onto this call and then finally .querySelector('article')
is used to get the appropriate article element.
So now that we have our 'active' article, first Jhey checks to see if our ARTICLE
variable is equal to our current
variable. This will never be true the first time it runs but in subsequent runs
it'll prevent unnecessary code from running if the users mouse moves but hasn't left the current article. After that check, it sets current
as equal to ARTICLE
for the reason I stated above.
Then we have an additional check to make sure current
evaluates as truthy
. To be honest, I haven't figured out a scenario in which it wouldn't be - the code works just fine for me without this
check. But better safe than sorry. Once that passes, we're getting into HOW our unordered list
pseudo-element moves around. First we grab the DomRect
of our current article. This will provide us a rectangle which will give us the left, top, right, bottom, x, y, width, and height
values of the current ARTICLE
.

src: MDN getBoundingClientRect()
Using these values, we can provide the information to our css variables that will update our inset
values of our unordered list
element. Because we have the transition set, it will
move smoothly between these values.
Final thoughts
And that's it! A very cool effect written by a very talented developer. Again I want to shout out Jhey on X. He's tremendously talented and you can really learn a lot from his examples and taking the time to understand and break them down. Don't just copy and paste! You won't learn anything that way.