Building a Drawer: The Versatility of Popover
How far can you get building a UI drawer with as little possible? What tricks and new APIs might be useful?
Recently, I got a request for a CSS-only drawer with “drag” support on mobile. This is tricky, but newer APIs like Popover make clever solutions possible. For example, morphing desktop navigation into a mobile drawer. So far can we get with some of these cutting-edge APIs?
The trick for the morph: Override user agent styles on desktop and lean into them on mobile styling the Popover as a drawer.
Popover API
If you haven't played with the Popover API, this is the "Hello World":
<div popover id="pop">I'm the Popover</div>
<button popovertarget="pop" popovertargetaction="toggle">Open Popover</button>
It promotes an element to the top layer (no z-index
needed) with free light dismiss and a styleable ::backdrop
.
No JavaScript required.
How far can CSS take you?
You can get far using a Popover and CSS.
Click to open the drawer. Click off or use the esc
key to dismiss. Some CSS and you can transition the entry/exit. Only Firefox is left to go for @starting-style
support.
.drawer {
/* This acts like a holding animation */
transition-property: display;
transition-behavior: allow-discrete;
transition-duration: var(--duration);
}
.drawer__content {
transition-property: translate;
transition-duration: var(--duration);
transition-timing-function: var(--ease);
translate: 0 100%;
}
.drawer:popover-open .drawer__content {
translate: 0 0;
@starting-style {
translate: 0 100%;
}
}
You could handle missing support with some WAAPI or at the very least using Element.getAnimations and toggling some classNames. Do not use a setTimeout
and hope for the best.
Gestures
How much can we get out of the browser for free? For a draggable drawer, CSS scroll-snap can do a lot.
Use a viewport-sized Popover, and put the drawer in a scroll container that uses scroll-snap
. Use snap anchors (with pointer-events: none
) to make the drawer snap into place.
This trick lets touch devices "drag" for free, while the desktop needs a few lines of JavaScript to update the scroll position on drag.
.drawer__scroller {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
scroll-snap-type: y mandatory;
/* Acts as a buffer */
&::after {
content: '';
width: 100%;
height: 100svh;
order: -1;
flex: 1 0 100svh;
}
}
/* The Anchors */
.drawer__anchors {
pointer-events: none;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.drawer__anchor {
height: 50px;
width: 100%;
scroll-snap-align: end;
&:first-of-type {
translate: 0 -100%;
}
}
Now you can slide the drawer up and down, but it doesn't close when you slide it down. We need some JavaScript.
A new event, scrollsnapchange
is perfect for us. When the snap changes, close the Popover if we've scrolled the drawer out.
scroller.addEventListener('scrollsnapchange', () => {
if (scroller.scrollTop === 0) {
drawer.dataset.snapped = true
drawer.hidePopover()
}
})
// handle removing the snapped tag and setting up IntersectionObserver
drawer.addEventListener('toggle', (event) => {
if (event.newState === 'closed') {
drawer.dataset.snapped = false
}
if (event.newState === 'open' && !('onsrollsnapchange' in window)) {
if (!observer) observer = new IntersectionObserver(callback, options)
observer.observe(topAnchor)
}
}
Note:: We use dataset
to manage when transitions should or should not happen in our CSS.
The Popover API gives you access to a toggle
event so you can see the state of a Popover (You also get a beforetoggle
event).
If there's no onscrollsnapchange
support, use an IntersectionObserver
to do the job.
Scroll-Driven Details
Now the mechanics are in place, we can have some fun. What about that action sheet effect where the body behind scales down?
We're using scroll so you could use CSS scroll-driven animation to style the backdrop as we move the drawer.
By driving the value of some CSS custom properties, you can animate the backdrop styling. For browsers where scroll-driven animations aren't supported, we can use a trick where we kick off a requestAnimationFrame
on scroll.
@supports (animation-timeline: scroll()) {
:root {
timeline-scope: --drawer;
}
:root:has(.drawer:popover-open) {
--closed: 1;
animation: open both linear reverse;
animation-timeline: --drawer;
animation-range: entry;
main { --opened: 1; }
}
.drawer__slide {
view-timeline: --drawer;
}
@keyframes open {
0% {
--closed: 0;
}
}
}
main {
--diff: calc(var(--opened) * var(--closed));
scale: calc(
1 - ((var(--opened) * 0.04) - (var(--diff) * 0.04))
);
}
Note:: This worked great on the body
in Chromium but broke in Safari. Looks like transitioning from the top layer with allow-discrete
doesn't work as expected. For this reason, we're using main
as the backdrop and scaling that down instead.
Visual Viewport and Software Keyboards
Using values like 100svh or 100dvh for layout is great but they don't adjust for on-screen keyboards. This means our drawer could get covered when we try to get user input.
As of Chrome 108, you can use the interactive-widget=resize-contents key to resize both the Visual and Layout Viewports. But it doesn't work on iOS Safari. To get around this, use the visualViewport
API to listen for the offset and resize the drawer via CSS. The idea is that you never want your drawer to get cut off because the keyboard pushed it up above the top of your screen:
const handleResize = () => {
document.documentElement.style.setProperty(
'--viewport-offset',
window.visualViewport.offsetTop
)
}
window.visualViewport?.addEventListener('resize', handleResize)
.drawer__content {
max-height: calc(95% - (var(--viewport-offset, 0) * 1px));
}
Great!
Recommend this article about “Dealing with the Visual Viewport”. It is up to you how you handle the Visual Viewport based on your design. This post on web.dev does a good job of laying out different behaviors.
The fun stuff!
When looking for designs to play with, I loved the idea of this sticky reaction bar that fired emojis. Some top padding on the drawer and display: flex
gives us the effect. The content flexes(flex: 1
) with overflow: auto
. The reaction bar sticks to the bottom on scroll with position: sticky
. Add a little full-screen canvas and you've got some emoji bursts for fun. When you let the browser handle as much of the not-so-fun stuff as possible, you can focus on building the fun things!
Demo Link: codepen.io/jh3y
Takeaways
Using the Popover API for disclosures
@starting-style
for entry/exit animationsscroll-snap tricks with
scrollsnapchange
CSS scroll-driven backdrops with
@property
VisualViewport resize listening and
interactive-widget=resize-contents
Grab a device! Check things out in the iOS Simulator or use USB debugging on Android via Chrome
How to cater for browser support issues with APIs like
IntersectionObserver
andrequestAnimationFrame