Pop(over) the Balloons
Publikováno: 25.7.2024
I’ve always been fascinated with how much we can do with just HTML and CSS. The new interactive features of the Popover API are yet another example of just how far we can get with those two languages alone.
You …
Pop(over) the Balloons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I’ve always been fascinated with how much we can do with just HTML and CSS. The new interactive features of the Popover API are yet another example of just how far we can get with those two languages alone.
You may have seen other tutorials out there showing off what the Popover API can do, but this is more of a beating-it-mercilessly-into-submission kind of article. We’ll add a little more popmusic to the mix, like with balloons… some literal “pop” if you will.
What I’ve done is make a game — using only HTML and CSS, of course — leaning on the Popover API. You’re tasked with popping as many balloons as possible in under a minute. But be careful! Some balloons are (as Gollum would say) “tricksy” and trigger more balloons.
I have cleverly called it Pop(over) the Balloons and we’re going to make it together, step by step. When we’re done it’ll look something like (OK, exactly like) this:
Handling the popover
attribute
Any element can be a popover as long as we fashion it with the popover
attribute:
<div popover>...</div>
We don’t even have to supply popover
with a value. By default, popover
‘s initial value is auto
and uses what the spec calls “light dismiss.” That means the popover can be closed by clicking anywhere outside of it. And when the popover opens, unless they are nested, any other popovers on the page close. Auto popovers are interdependent like that.
The other option is to set popover
to a manual
value:
<div popover=“manual”>...</div>
…which means that the element is manually opened and closed — we literally have to click a specific button to open and close it. In other words, manual
creates an ornery popup that only closes when you hit the correct button and is completely independent of other popovers on the page.
Using the <details>
element as a starter
One of the challenges of building a game with the Popover API is that you can’t load a page with a popover already open… and there’s no getting around that with JavaScript if our goal is to build the game with only HTML and CSS.
Enter the <details>
element. Unlike a popover, the <details>
element can be open by default:
<details open>
<!-- rest of the game -->
</details>
If we pursue this route, we’re able to show a bunch of buttons (balloons) and “pop” all of them down to the very last balloon by closing the <details>
. In other words, we can plop our starting balloons in an open <details>
element so they are displayed on the page on load.
This is the basic structure I’m talking about:
<details open>
<summary>🎈</summary>
<button>🎈</button>
<button>🎈</button>
<button>🎈</button>
</details>
In this way, we can click on the balloon in <summary>
to close the <details>
and “pop” all of the button balloons, leaving us with one balloon (the <summary>
at the end (which we’ll solve how to remove a little later).
You might think that <dialog>
would be a more semantic direction for our game, and you’d be right. But there are two downsides with <dialog>
that won’t let us use it here:
- The only way to close a
<dialog>
that’s open on page load is with JavaScript. As far as I know, there isn’t a close<button>
we can drop in the game that will close a<dialog>
that’s open on load. <dialog>
s are modal and prevent clicking on other things while they’re open. We need to allow gamers to pop balloons outside of the<dialog>
in order to beat the timer.
Thus we will be using a <details
open>
element as the game’s top-level container and using a plain ol’ <div>
for the popups themselves, i.e. <div popover>
.
All we need to do for the time being is make sure all of these popovers and buttons are wired together so that clicking a button opens a popover. You’ve probably learned this already from other tutorials, but we need to tell the popover element that there is a button it needs to respond to, and then tell the button that there is a popup it needs to open. For that, we give the popover element a unique ID (as all IDs should be) and then reference it on the <button>
with a popovertarget
attribute:
<!-- Level 0 is open by default -->
<details open>
<summary>🎈</summary>
<button popovertarget="lvl1">🎈</button>
</details>
<!-- Level 1 -->
<div id="lvl1" popover="manual">
<h2>Level 1 Popup</h2>
</div>
This is the idea when everything is wired together:
Opening and closing popovers
There’s a little more work to do in that last demo. One of the downsides to the game thus far is that clicking the <button>
of a popup
opens more popups; click that same <button>
again and they disappear. This makes the game too easy.
We can separate the opening and closing behavior by setting the popovertargetaction
attribute (no, the HTML spec authors were not concerned with brevity) on the <button>
. If we set the attribute value to either show
or hide
, the <button>
will only perform that one action for that specific popover.
<!-- Level 0 is open by default -->
<details open>
<summary>🎈</summary>
<!-- Show Level 1 Popup -->
<button popovertarget="lvl1" popovertargetaction="show">🎈</button>
<!-- Hide Level 1 Popup -->
<button popovertarget="lvl1" popovertargetaction="hide">🎈</button>
</details>
<!-- Level 1 -->
<div id="lvl1" popover="manual">
<h2>Level 1 Popup</h2>
<!-- Open/Close Level 2 Poppup -->
<button popovertarget="lvl2">🎈</button>
</div>
<!-- etc. -->
Note, that I’ve added a new <button>
inside the <div>
that is set to target another <div>
to pop open or close by intentionally not setting the popovertargetaction
attribute on it. See how challenging (in a good way) it is to “pop” the elements:
Styling balloons
Now we need to style the <summary>
and <button>
elements the same so that a player cannot tell which is which. Note that I said <summary>
and not<details>
. That’s because <summary>
is the actual element we click to open and close the <details>
container.
Most of this is pretty standard CSS work: setting backgrounds, padding, margin, sizing, borders, etc. But there are a couple of important, not necessarily intuitive, things to include.
- First, there’s setting the
list-style-type
property tonone
on the<summary>
element to get rid of the triangular marker that indicates whether the<details>
is open or closed. That marker is really useful and great to have by default, but for a game like this, it would be better to remove that hint for a better challenge. - Safari doesn’t like that same approach. To remove the
<details>
marker here, we need to set a special vendor-prefixed pseudo-element,summary::-webkit-details-marker
todisplay: none
. - It’d be good if the mouse cursor indicated that the balloons are clickable, so we can set
cursor: pointer
on the<summary>
elements as well. - One last detail is setting the
user-select
property tonone
on the<summary>
s to prevent the balloons — which are simply emoji text — from being selected. This makes them more like objects on the page. - And yes, it’s 2024 and we still need that prefixed
-webkit-user-select
property to account for Safari support. Thanks, Apple.
Putting all of that in code on a .balloon
class we’ll use for the <button>
and <summary>
elements:
.balloon {
background-color: transparent;
border: none;
cursor: pointer;
display: block;
font-size: 4em;
height: 1em;
list-style-type: none;
margin: 0;
padding: 0;
text-align: center;
-webkit-user-select: none; /* Safari fallback */
user-select: none;
width: 1em;
}
One problem with the balloons is that some of them are intentionally doing nothing at all. That’s because the popovers they close are not open. The player might think they didn’t click/tap that particular balloon or that the game is broken, so let’s add a little scaling while the balloon is in its :active
state of clicking:
.balloon:active {
scale: 0.7;
transition: 0.5s;
}
Bonus: Because the cursor
is a hand pointing its index finger, clicking a balloon sort of looks like the hand is poking the balloon with the finger. 👉🎈💥
The way we distribute the balloons around the screen is another important thing to consider. We’re unable to position them randomly without JavaScript so that’s out. I tried a bunch of things, like making up my own “random” numbers defined as custom properties that can be used as multipliers, but I couldn’t get the overall result to feel all that “random” without overlapping balloons or establishing some sort of visual pattern.
I ultimately landed on a method that uses a class to position the balloons in different rows and columns — not like CSS Grid or Multicolumns, but imaginary rows and columns based on physical insets. It’ll look a bit Grid-like and is less “randomness” than I want, but as long as none of the balloons have the same two classes, they won’t overlap each other.
I decided on an 8×8 grid but left the first “row” and “column” empty so the balloons are clear of the browser’s left and top edges.
/* Rows */
.r1 { --row: 1; }
.r2 { --row: 2; }
/* all the way up to .r7 */
/* Columns */
.c1 { --col: 1; }
.c2 { --col: 2; }
/* all the way up to .c7 */
.balloon {
/* This is how they're placed using the rows and columns */
top: calc(12.5vh * (var(--row) + 1) - 12.5vh);
left: calc(12.5vw * (var(--col) + 1) - 12.5vw);
}
Congratulating The Player (Or Not)
We have most of the game pieces in place, but it’d be great to have some sort of victory dance popover to congratulate players when they successfully pop all of the balloons in time.
Everything goes back to a <details
open>
element. Once that element is notopen
, the game should be over with the last step being to pop that final balloon. So, if we give that element an ID of, say, #root
, we could create a condition to hide it with display: none
when it is :not()
in an open
state:
#root:not([open]) {
display: none;
}
This is where it’s great that we have the :has()
pseudo-selector because we can use it to select the #root
element’s parent element so that when #root
is closed we can select a child of that parent — a new element with an ID of #congrats
— to display a faux popover displaying the congratulatory message to the player. (Yes, I’m aware of the irony.)
#game:has(#root:not([open])) #congrats {
display: flex;
}
If we were to play the game at this point, we could receive the victory message without popping all the balloons. Again, manual popovers won’t close unless the correct button is clicked — even if we close its ancestral <details>
element.
Is there a way within CSS to know that a popover is still open? Yes, enter the :popover-open
pseudo-class.
The :popover-open
pseudo-class selects an open popover. We can use it in combination with :has()
from earlier to prevent the message from showing up if a popover is still open on the page. Here’s what it looks like to chain these things together to work like an and
conditional statement.
/* If #game does *not* have an open #root
* but has an element with an open popover
* (i.e. the game isn't over),
* then select the #congrats element...
*/
#game:has(#root:not([open])):has(:popover-open) #congrats {
/* ...and hide it */
display: none;
}
Now, the player is only congratulated when they actually, you know, win.
Conversely, if a player is unable to pop all of the balloons before a timer expires, we ought to inform the player that the game is over. Since we don’t have an if()
conditional statement in CSS (not yet, at least) we’ll run an animation for one minute so that this message fades in to end the game.
#fail {
animation: fadein 0.5s forwards 60s;
display: flex;
opacity: 0;
z-index: -1;
}
@keyframes fadein {
0% {
opacity: 0;
z-index: -1;
}
100% {
opacity: 1;
z-index: 10;
}
}
But we don’t want the fail message to trigger if the victory screen is showing, so we can write a selector that prevents the #fail
message from displaying at the same time as #congrats
message.
#game:has(#root:not([open])) #fail {
display: none;
}
We need a game timer
A player should know how much time they have to pop all of the balloons. We can create a rather “simple” timer with an element that takes up the screen’s full width (100vw
), scaling it in the horizontal direction, then matching it up with the animation above that allows the #fail
message to fade in.
#timer {
width: 100vw;
height: 1em;
}
#bar {
animation: 60s timebar forwards;
background-color: #e60b0b;
width: 100vw;
height: 1em;
transform-origin: right;
}
@keyframes timebar {
0% {
scale: 1 1;
}
100% {
scale: 0 1;
}
}
Having just one point of failure can make the game a little too easy, so let’s try adding a second <details>
element with a second “root” ID, #root2
. Once more, we can use :has
to check that neither the #root
nor #root2
elements are open
before displaying the #congrats
message.
#game:has(#root:not([open])):has(#root2:not([open])) #congrats {
display: flex;
}
Wrapping up
The only thing left to do is play the game!
Fun, right? I’m sure we could have built something more robust without the self-imposed limitation of a JavaScript-free approach, and it’s not like we gave this a good-faith accessibility pass, but pushing an API to the limit is both fun and educational, right?
I’m interested: What other wacky ideas can you think up for using popovers? Maybe you have another game in mind, some slick UI effect, or some clever way of combining popovers with other emerging CSS features, like anchor positioning. Whatever it is, please share!
Pop(over) the Balloons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.