DEV Community

Cover image for Native-like bottom sheets on the web: the power of modern CSS
Vili Ketonen
Vili Ketonen

Posted on • Originally published at viliket.github.io

Native-like bottom sheets on the web: the power of modern CSS

With modern web features like CSS scroll snap, it is now possible to create native-like bottom sheets with advanced features such as multiple snap points and nested scrolling – with close to zero JavaScript – bringing the smoothest possible user experience by relying on the browser's own scroll functionality. Even better, we can utilize built-in accessibility support by leveraging native HTML features, such as the <dialog> element and the Popover API. These powerful CSS capabilities led me to create pure-web-bottom-sheet, a lightweight library that provides a framework-agnostic web component with native-comparable performance and the smooth, responsive behavior users expect from bottom sheets.

Why build another bottom sheet component?

As a personal open source project, I have been developing Junaan.fi, a web application built with React that displays train schedules, locations, and compositions of trains in Finland on a map. As the map became the central UI element, I needed a bottom sheet component that would allow the map to remain full-screen while users could access secondary content.

I evaluated several well-established existing options including,
react-spring-bottom-sheet, react-modal-sheet, Vaul and Plain sheet. While these libraries work well for many scenarios, I found they did not quite match the specific requirements of my project. Specifically, I needed a component that could handle long scrollable content overlaid on a map element while supporting multiple snap points and delivering steady drag interaction on touch-based devices. After thorough testing, I found that achieving the smooth user experience I was looking for would require a different approach than using JavaScript-driven dragging animations that are prone to jank. This gap in the currently available solutions led me to explore how modern CSS and new web features could create a more responsive, native-like bottom sheet experience, particularly for touch-based mobile interactions.

Key design principles

I created pure-web-bottom-sheet as a pure Web Component to ensure flexibility across projects, regardless of the frontend framework. The implementation focuses on several key principles:

  1. Native scroll-driven sheet movement - Uses the browser's own scroll mechanics instead of JavaScript-driven animations to adjust the sheet position through CSS scroll snapping
  2. Framework agnostic - Built as a pure vanilla Web Component to work with any frontend framework - it uses the standard Web Components APIs directly without additional abstraction libraries to minify bundle size, eliminate framework dependencies, and improve runtime performance by avoiding unnecessary abstractions.
  3. Accessibility built-in - Leverages native HTML elements like <dialog> and the Popover API without reinventing the wheel
  4. Server-side rendering (SSR) support - Provides a Declarative Shadow DOM template for SSR compatibility
  5. Declarative configuration - Uses HTML attributes and slots for defining the bottom sheet layout similar to the built-in web components

Demo and various examples

Here is a simple Codepen demo showcasing the pure-web-bottom-sheet as a modal with multiple snap points:

You can view the live demo and more examples in https://viliket.github.io/pure-web-bottom-sheet/.

How it works: The technical approach and core mechanics

The following sections explain the core mechanics and implementation details behind the bottom sheet component.

Component architecture

The bottom sheet component follows patterns similar to built-in elements with declarative configuration through composed structure and HTML attributes:

<bottom-sheet>
  <template shadowrootmode="open">
    <!-- Declarative shadow root can be included to support SSR -->
  </template>

  <!-- Snap points can defined declaratively -->
  <div slot="snap" style="--snap: 25%"></div>
  <div slot="snap" style="--snap: 50%" class="initial"></div>
  <div slot="snap" style="--snap: 75%"></div>

  <!-- Flexible content structure with named slots -->
  <div slot="header">
    <h2>Custom header</h2>
  </div>

  <!-- Main content (default unnamed slot) -->
  Custom content goes here

  <div slot="footer">
    <h2>Custom footer</h2>
  </div>
</bottom-sheet>
Enter fullscreen mode Exit fullscreen mode

The internal shadow DOM structure provides the foundation for the component's behavior:

<template>
  <style>
    /* Styles */
  </style>
  <slot name="snap">
    <!-- Placeholder: default single snap point at the maximum height -->
    <div class="snap initial" style="--snap: 100%"></div>
  </slot>
  <div class="snap snap-bottom"></div>
  <div class="sheet-wrapper">
    <aside class="sheet" part="sheet">
      <header class="sheet-header" part="header">
        <div class="handle" part="handle"></div>
        <slot name="header"></slot>
      </header>
      <section class="sheet-content" part="content">
        <slot></slot>
      </section>
      <footer class="sheet-footer" part="footer">
        <slot name="footer"></slot>
      </footer>
    </aside>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Scroll-based layout: the core principle

The bottom sheet component leverages native scrolling with CSS scroll snap instead of JavaScript-based animations to adjust the sheet position. By utilizing the browser's built-in scrolling physics, we achieve a smoother experience since the browser handles the scrolling on the compositor thread without affecting the main thread. Setting scroll-snap-type: y mandatory on the host element enables vertical scroll snapping and ensures the sheet always lands on a defined snap point after a scroll gesture:

:host {
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
}
Enter fullscreen mode Exit fullscreen mode

This approach transforms the host element into a "scrolling track" for the
nested sheet element, creating the foundation for our bottom sheet behavior. A similar technique is also used by Apple Maps on the web and featured in the article Building a Drawer: The Versatility of Popover by Jhey Tompkins.

Configurable snap points

A key feature of a bottom sheet is the ability to "snap" to different preset points. Our bottom sheet component implements this using a named snap slot that allows users to define multiple snap points declaratively:

<bottom-sheet>
  <!-- Define three snap points at 25%, 50%, and 75% of viewport height -->
  <div slot="snap" style="--snap: 25vh"></div>
  <div slot="snap" style="--snap: 50vh" class="initial"></div>
  <div slot="snap" style="--snap: 75vh"></div>
</bottom-sheet>
Enter fullscreen mode Exit fullscreen mode

Each snap point is positioned using a CSS custom property --snap that specifies its distance from the bottom of the viewport. The initial class designates which snap point the sheet should initially snap to when opened (more of that in a later section).

Later, when the browser support for the CSS attr() function improves, we could alternatively use a data-* attribute-based approach (e.g., data-snap="25vh") instead of the --snap custom property to define the snap point value and then access it in CSS using top: attr(data-snap length-percentage).

How it works

The shadow DOM of the component contains the following key elements that enable the snap behavior:

<template>
  <!-- ... -->
  <slot name="snap">
    <!-- Default snap point if none provided -->
    <div class="snap initial" style="--snap: 100%"></div>
  </slot>
  <!-- 
   Creates an invisible area as a "track" for the sheet surface to move vertically
   along
  -->
  <div class="snap snap-bottom"></div>
  <div class="sheet-wrapper">
    <!-- The actual visible sheet surface -->
    <aside class="sheet" part="sheet">
      <!-- ... -->
    </aside>
  </div>
  <!-- ... -->
</template>
Enter fullscreen mode Exit fullscreen mode

These elements leverage the CSS scroll snap through the following CSS:

/* Style for snap points (both slotted and default) */
.snap,
::slotted([slot="snap"]) {
  position: relative;
  top: var(--snap);
}

.snap::before,
::slotted([slot="snap"])::before {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: 1px; /* Height required for Safari to snap */
  scroll-snap-align: var(--snap-point-align, start);
  content: "";
}

/* 
  Special element that creates the scrollable area for the host element acting
  as the "track"
*/
.snap.snap-bottom {
  position: static;
  top: initial;
  height: auto;

  &::after {
    display: block;
    position: static;
    height: var(--sheet-max-height);
    scroll-snap-align: none;
    content: "";
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is how the snap mechanism, supported by the elements and CSS presented
above, works in practice:

  1. The .snap-bottom element creates an invisible scrollable "track" together with its ::after pseudo-element. This track has a height equal to the maximum height of the bottom sheet component.
  2. Each snap point element is positioned relative to the top of the host element using top: var(--snap):
    • A snap point with --snap: 0% corresponds to the top of the host, where the sheet's top edge is just outside the bottom of the host's scrollport due to the height of the .snap-bottom::after element
    • A snap point with --snap: 100% corresponds to the offset of 100% from the top of the host at which point the sheet is fully extended and fully occupies the host's scrollport
    • Values between 0% and 100% create intermediate snap positions
  3. When the user drags the sheet, the browser's native scroll snapping takes over. As the host element scrolls, it "snaps" to these positioned elements, creating a smooth, native-like feel without any JavaScript-driven animations.

Illustration of the bottom sheet snapping mechanism

This approach allows the users to define any number of snap points with simple declarative HTML, while the browser handles all the positioning and scroll snap behavior automatically using pure CSS specified by the component.

Initial snap point selection

To specify which snap point the bottom sheet should initially snap to when it is displayed for the first time or reopened, users can add the initial class to the desired snap point. To ensure the sheet snaps to this point each time it is displayed, the component uses a trick inspired by the "Snappy Scroll-Start" technique presented by Roma Komarov.

The host element has a brief animation (0.01s) which sets a custom property
--snap-point-align: none as the start state of the animation. This custom property is used by the snap points and the sheet element to set their scroll-snap-align property. The snap point assigned with the initial class overrides the custom property as --snap-point-align: start, so it is unaffected by the animation, forcing the host element's scrollport to initially snap to the initial snap point, leveraging the Re-snapping After Layout Changes feature of the CSS scroll snap module.

:host {
  animation: initial-snap 0.01s both;
}

.snap::before,
::slotted([slot="snap"])::before,
.sheet {
  scroll-snap-align: var(--snap-point-align, start);
}

.snap.initial,
::slotted([slot="snap"].initial) {
  --snap-point-align: start;
}

/*
  Temporarily disables scroll snapping for all snap points
  except the explicitly marked initial snap point (which overrides
  --snap-point-align) so that the sheet snaps to
  the initial snap point.
*/
@keyframes initial-snap {
  0% {
    --snap-point-align: none;
  }
  50% {
    /* 
      Needed for the iOS Safari
      See https://stackoverflow.com/q/65653679
    */
    scroll-snap-type: initial;
    --snap-point-align: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note also that while the CSS Scroll Snap Module Level 2 specification has introduced a new scroll-initial-target property, it is unsuitable for the bottom sheet's initial snap point feature since the browser only applies the scroll initial target once (at least currently on Chromium), not every time the element display is toggled, which would not work for the bottom sheet where the initial snap point should apply each time the bottom sheet is reopened.

Advanced features

Sections below describe additional functionalities the bottom sheet component supports.

Pointer event handling for non-modal sheets

The bottom sheet component supports both modal (blocking underlying content) and non-modal (allowing page interaction) modes. For the non-modal bottom sheet behavior, the component manages the pointer events in the following way: the host element (which acts as the scrolling container) uses CSS declaration pointer-events: none to become "click-through", while the sheet element uses pointer-events: all to capture interactions. This approach enables users to both manipulate the sheet by interacting with its surface and access the underlying page content when needed:

:host {
  pointer-events: none;
}

.sheet {
  pointer-events: all;
}
Enter fullscreen mode Exit fullscreen mode

While this solution works seamlessly in most browsers, iOS Safari currently
requires a workaround due to a Webkit-specific bug. In iOS Safari, when a parent element has pointer-events: none, scroll interactions on child elements with pointer-events: auto fail to propagate properly to the parent scroll container - a critical issue for our bottom sheet architecture. Fortunately, developer J.J. Johnson documented a workaround that tricks iOS Safari into correctly handling the scroll behavior by adding a small horizontal overflow to the interactable child elements. Here is how the bottom sheet component implements this:

@supports (-webkit-touch-callout: none) {
  .sheet-content,
  .sheet-header,
  .sheet-footer {
    overflow-x: scroll;
    overscroll-behavior-x: none;
    scrollbar-width: none;

    &::after {
      display: block;
      box-sizing: content-box;
      padding: inherit;
      padding-left: 0;
      width: calc(100% + 1px);
      height: 1px;
      content: "";
    }
  }
  .sheet-content {
    scrollbar-width: auto;
  }
}
Enter fullscreen mode Exit fullscreen mode

Nested scrolling with CSS scroll-driven animations

Bottom sheets commonly also support a "nested scrolling" mode that allows the user to scroll the nested sheet content independently from the sheet itself. To implement this behavior, our component leverages
CSS scroll-driven animations - activated by adding the nested-scroll attribute to the host element. With this attribute set, the sheet element height animates in response to the host element's vertical scroll position:

:host {
  scroll-timeline: --sheet-timeline y;
}

:host([nested-scroll]) .sheet {
  animation: expand-sheet-height linear forwards;
  animation-timeline: --sheet-timeline;
}

@keyframes expand-sheet-height {
  from {
    height: 0;
  }
  to {
    height: 100%;
  }
}
Enter fullscreen mode Exit fullscreen mode

For browsers that do not yet support scroll-driven animations, the component implements a JavaScript fallback that updates a custom property
--sheet-position by listening to the scroll event on the host element to adjust the height of the sheet element:

@supports (
  not ((animation-timeline: scroll()) and (animation-range: 0% 100%))
) {
  :host([nested-scroll]) .sheet {
    height: calc(var(--sheet-position) - var(--sw-keyboard-height, 0) * 1px);
  }
}
Enter fullscreen mode Exit fullscreen mode

The approach described above is simple and requires no JavaScript for modern browsers supporting scroll-driven animations. However, in general, it is not advisable to animate the element height (or any other "geometric properties") since this causes relayout (also called "reflow"), which is an expensive operation for the CPU, particularly when the affected DOM is large. In our case, the reflow caused by the height animation affects only the sheet's inner contents, but it can still get expensive if the inner DOM is complex, which depends on the context where the bottom sheet component is used.

To avoid potential performance issues when animating the height property, the component supports a nested-scroll-optimization attribute to enable a
performance optimization for this feature. When enabled, this optimization
switches from expensive height animation (which triggers layout recalculations) to a more efficient transform-based animation during active scrolling. These transform-based animations run smoothly on the browser's compositor thread without blocking the main thread.

Here is how this JavaScript-backed optimization works: when a scroll event
begins, the component:

  1. Toggles a data-scrolling attribute
  2. Calculates appropriate values for --sheet-content-offset-start and --sheet-content-offset-end based on the current scroll position of the nested sheet content (note that this computation is only done once when the scrolling begins)
  3. Uses these computed values in the CSS keyframe animation, which is driven by the host element's scroll timeline

This optimization significantly improves scrolling performance for bottom sheets containing complex DOM structures. Here is the CSS behind this optimization:

:host([nested-scroll]:not([expand-to-scroll])[data-scrolling]) {
  .sheet-content {
    /* Hide the scrollbar visually during scrolling */
    scrollbar-color: transparent transparent;
  }
}

@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
  :host([nested-scroll]:not([expand-to-scroll])[data-scrolling]) {
    .sheet {
      animation: translate-sheet linear forwards;
      animation-timeline: --sheet-timeline;
    }

    .sheet-content {
      animation: translate-sheet-content linear forwards;
      animation-timeline: --sheet-timeline;
    }

    .sheet-footer {
      animation: translate-footer linear forwards;
      animation-timeline: --sheet-timeline;
    }
  }

  @keyframes translate-sheet {
    from {
      transform: translateY(100%);
    }
    to {
      transform: translateY(0);
    }
  }

  @keyframes translate-sheet-content {
    from {
      transform: translateY(var(--sheet-content-offset-start, 0));
    }
    to {
      transform: translateY(var(--sheet-content-offset-end, 0));
    }
  }

  @keyframes translate-footer {
    from {
      transform: translateY(calc(-1 * var(--sheet-safe-max-height)));
    }
    to {
      transform: translateY(0);
    }
  }
}

/* Fallback for browsers that do not yet support scroll-driven animations */
@supports (
  not ((animation-timeline: scroll()) and (animation-range: 0% 100%))
) {
  :host([nested-scroll]:not([expand-to-scroll])[data-scrolling]) {
    .sheet {
      height: 100%;
      transform: translateY(calc(100% - var(--sheet-position, 0)));
    }

    .sheet-content {
      transform: translateY(var(--sheet-content-offset, 0));
    }

    .sheet-footer {
      transform: translateY(
        calc(-1 * var(--sheet-safe-max-height) + var(--sheet-position, 0))
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Cross-browser compatibility

Currently, there are variations in how browser engines implement the
Re-snapping After Layout Changes part of the CSS scroll snap module. While Chromium preserves scroll position within the snapped element after layout changes, Firefox and Safari re-snap to the top of the snapped element. To address this discrepancy, our component uses IntersectionObserver to detect when the sheet scrolls beyond the top (indicated by data-sheet-snap-position="-1") and temporarily disables snap alignment accordingly to avoid abrupt re-snaps when the sheet's inner layout changes due to dynamic content:

:host([data-sheet-snap-position="-1"]) {
  .sheet,
  .snap,
  ::slotted([slot="snap"]) {
    scroll-snap-align: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

On-screen keyboard handling

One important aspect for bottom sheets is to handle the on-screen keyboard
without making the sheet content inaccessible while the keyboard is visible. To support this, the component leverages the keyboard-inset-bottom CSS environment variable from VirtualKeyboard API to adjust its height and bottom margin. However, since the VirtualKeyboard API is currently only supported by Chromium browsers, the component implements a JavaScript fallback that listens to visual viewport resize events and updates a custom property --sw-keyboard-height, which the component uses to control the maximum height of the host element and .snap-bottom::after pseudo-element:

:host {
  --sheet-safe-max-height: calc(
    var(--sheet-max-height) - env(keyboard-inset-height, var(--sw-keyboard-height, 0px))
  );
  max-height: var(--sheet-safe-max-height);
  bottom: env(keyboard-inset-height, 0);
}

.snap.snap-bottom::after {
  max-height: var(--sheet-safe-max-height);
}
Enter fullscreen mode Exit fullscreen mode

Additionally, most browsers, apart from Safari, also support the interactive-widget attribute on the "viewport" <meta> element to control how UI widgets like virtual keyboards affect the viewport, which can also be used to solve the same
problem depending on the use case.

Declarative shadow DOM and server-side rendering

Declarative Shadow DOM, now baseline across all major browsers (since August 2024), enables full server-side rendering of the bottom sheet component:

<bottom-sheet>
  <template shadowrootmode="open">
    <!-- Shadow DOM content -->
  </template>
  <!-- Light DOM content -->
</bottom-sheet>
Enter fullscreen mode Exit fullscreen mode

Using the declarative shadow DOM is particularly beneficial when the application needs the bottom sheet to appear open on page load, since it prevents the flash of unstyled content (FOUC) problem.

Integration with native HTML components

A key advantage of this CSS-first, web-component-based approach is its easy
interoperability with native HTML dialog and popover API. By leveraging these native features, we gain built-in accessibility support, keyboard navigation, and proper focus management without additional JavaScript code.

Dialog integration: Modal bottom sheets with native accessibility

For modal bottom sheets, we can leverage the native <dialog> element to handle focus trapping and accessibility automatically. Since browsers like Firefox and Safari do not support customized built-in elements, we cannot directly extend the <dialog> element. Therefore, we use a companion custom element <bottom-sheet-dialog-manager> to augment the native dialog with swipe-to-dismiss functionality and smooth CSS transform-based slide transitions:

<bottom-sheet-dialog-manager>
  <dialog id="bottom-sheet-dialog">
    <bottom-sheet swipe-to-dismiss tabindex="0">
      Custom content goes here
    </bottom-sheet>
  </dialog>
</bottom-sheet-dialog-manager>

<button id="show-button">Open bottom sheet</button>

<script type="module">
  import { registerSheetElements } from "./path/to/pure-web-bottom-sheet";
  registerSheetElements();

  document.getElementById("show-button").addEventListener("click", () => {
    document.getElementById("bottom-sheet-dialog").showModal();
  });
</script>
Enter fullscreen mode Exit fullscreen mode
::slotted(dialog) {
  position: fixed;
  margin: 0;
  inset: 0;
  top: initial;
  border: none;
  background: unset;
  padding: 0;
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
}

::slotted(dialog:not(:modal)) {
  pointer-events: none;
}

::slotted(dialog[open]) {
  translate: 0 0;
}

@starting-style {
  ::slotted(dialog[open]) {
    translate: 0 100vh;
  }
}

::slotted(dialog) {
  translate: 0 100vh;
  transition: translate 0.5s ease-out, overlay 0.5s ease-out allow-discrete,
    display var(--display-transition-duration, 0.5s) ease-out allow-discrete;
}

/* Snap position "2" corresponds to the fully collapsed state */
:host([data-sheet-snap-position="2"]) ::slotted(dialog:not([open])) {
  transition: none;
}
Enter fullscreen mode Exit fullscreen mode

The component implements the swipe-to-dismiss feature as follows: when the sheet is swiped to the bottom snap point (based on the native scrollsnapchange event with the IntersectionObserver used as a fallback for non-supporting browsers), the <bottom-sheet> element emits a custom snap-position-change event that bubbles. The dialog manager listens for this event and automatically closes the dialog, creating a smooth dismiss gesture:

// Inside the `<bottom-sheet-dialog-manager>` implementation
this.addEventListener(
  "snap-position-change",
  (event: CustomEventInit<{ snapPosition: string }> & Event) => {
    if (event.detail) {
      this.dataset.sheetSnapPosition = event.detail.snapPosition;
    }
    if (
      // Snap position "2" corresponds to the collapsed (closed) state
      event.detail?.snapPosition == "2" &&
      event.target instanceof HTMLElement &&
      event.target.hasAttribute("swipe-to-dismiss") &&
      event.target.checkVisibility()
    ) {
      const parent = event.target.parentElement;
      if (
        parent instanceof HTMLDialogElement &&
        // Prevent Safari from closing the dialog immediately after opening
        // while the dialog open transition is still running.
        getComputedStyle(parent).getPropertyValue("translate") === "0px"
      ) {
        parent.close();
      }
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Popover integration: lightweight non-modal bottom sheets

For non-modal or lightweight bottom sheets, the native Popover API offers built-in focus management, light-dismiss functionality, and accessibility – all handled by the browser. Enabling the popover functionality only requires adding the popover attribute to the bottom sheet:

<bottom-sheet
  swipe-to-dismiss
  popover="auto"
  autofocus
  id="bottom-sheet-1"
  role="dialog"
  aria-modal="true"
>
  Bottom sheet contents
</bottom-sheet>

<button popovertargetaction="toggle" popovertarget="bottom-sheet-1">
  Toggle bottom sheet
</button>
Enter fullscreen mode Exit fullscreen mode

To further enhance usability, we can implement smooth open and close animations with a few additional lines of CSS. The following example demonstrates slide-up transitions and backdrop fade effects synchronized with the sheet's scroll position:

:root {
  --transition-duration: 0.5s;
  --transition-timing: ease-out;
}

@property --sheet-open {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

bottom-sheet[popover]:popover-open {
  translate: 0 0;
  --sheet-open: 1;
}

@starting-style {
  bottom-sheet[popover]:popover-open {
    translate: 0 100vh;
    --sheet-open: 0;
  }
}

bottom-sheet[popover] {
  transition: --sheet-open var(--transition-duration) var(--transition-timing), translate
      var(--transition-duration) var(--transition-timing),
    overlay var(--transition-duration) var(--transition-timing) allow-discrete, display
      var(--transition-duration) var(--transition-timing) allow-discrete;
  translate: 0 100vh;
}

bottom-sheet[popover]:not(:popover-open) {
  display: none;
}

bottom-sheet[popover]::backdrop {
  animation: fade-in linear forwards;
  animation-timeline: --sheet-timeline;
  animation-range-end: 100vh;
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: calc(1 * var(--sheet-open));
  }
}
Enter fullscreen mode Exit fullscreen mode

The CSS above demonstrates several modern web platform features:

  • CSS custom property --sheet-open registered with the @property rule to enable smooth transition between the sheet open and closed state
  • @starting-style for popover entry effect when the bottom sheet is opened
  • Animation timeline tied to sheet scroll position (--sheet-timeline)
  • The transition-behavior: allow-discrete rule to enable transitions between discrete states (animating the popover from display: none, and animating into the top layer)

The result is a popover-based bottom sheet that smoothly slides up when opened, with its backdrop fading in as the sheet rises, and animates away gracefully when dismissed – all achieved with zero JavaScript, except for the swipe-to-dismiss feature to close the popover, which requires the following addition:

bottomSheet.addEventListener(
  "snap-position-change",
  (event: CustomEventInit<{ snapPosition: string }> & Event) => {
    if (
      // Snap position "2" corresponds to the collapsed (closed) state
      event.detail?.snapPosition == "2" &&
      event.target instanceof HTMLElement &&
      event.target.hasAttribute("swipe-to-dismiss") &&
      event.target.checkVisibility() &&
      // Prevent Safari from closing the popover immediately after opening
      // while the popover open transition is still running.
      getComputedStyle(event.target).getPropertyValue("translate") === "0px"
    ) {
      event.target.hidePopover();
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

The code above adds an event listener to the bottom sheet element, which listens for the custom event snap-position-change that the element dispatches when its snap position changes.

Usage in various frameworks

Since the bottom sheet is built as a pure vanilla Web Component, it can be used with any frontend framework or in vanilla HTML. The following sections show how to integrate it with popular frameworks.

Astro

Astro has built-in support for web components, and it also supports directly defining the declarative shadow DOM (<template> element with a shadowrootmode attribute) on the server side:

---
import { bottomSheetTemplate } from "pure-web-bottom-sheet/ssr";
---

<bottom-sheet {...Astro.props}>
  <template shadowrootmode="open">
    <Fragment set:html={bottomSheetTemplate} />
  </template>
  <div slot="snap" style="--snap: 25%"></div>
  <div slot="snap" style="--snap: 50%" class="initial"></div>
  <div slot="snap" style="--snap: 75%"></div>

  <div slot="header">
    <h2>Custom header</h2>
  </div>
  <div slot="footer">
    <h2>Custom footer</h2>
  </div>

  Custom content
</bottom-sheet>

<script>
  import { BottomSheet } from "pure-web-bottom-sheet";
  customElements.define("bottom-sheet", BottomSheet);
</script>
Enter fullscreen mode Exit fullscreen mode

React (with Next.js for server side rendering)

With React, the declarative shadow DOM support requires a measure against the following problem: React hydration fails with declarative shadow root because the browser DOM parser immediately upgrades the <template shadowrootmode="open"> element to a ShadowRoot, causing a hydration mismatch (related issue). The solution, implemented in the pure-web-bottom-sheet library, is to create a wrapper component that renders the template element only on the server to avoid the hydration mismatch:

// ShadowRootTemplate.tsx
"use client";

const isServer = typeof window === "undefined";

export default function Template({ html }: { html: string }) {
  if (isServer) {
    return (
      <template
        shadowrootmode="open"
        dangerouslySetInnerHTML={{
          __html: html,
        }}
      />
    );
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode
// BottomSheet.tsx
import ShadowRootTemplate from "./ShadowRootTemplate";

export default function BottomSheet({
  children,
  ...props
}: WebComponentProps<BottomSheetElement>) {
  return (
    <>
      <bottom-sheet {...props}>
        {<ShadowRootTemplate html={bottomSheetTemplate} />}
        {children}
      </bottom-sheet>
      <Client />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Vue (with Nuxt for server side rendering)

Vue has a similar hydration challenge with the declarative shadow DOM to React, which our bottom sheet tackles the same way as in React: rendering the template element only on the server-side:

<!-- ShadowRootTemplate.vue -->
<script setup lang="ts">
import { h, Fragment } from "vue";

const props = defineProps({
  html: {
    type: String,
    required: true,
  },
});

const isServer = typeof window === "undefined";

const renderShadowRootTemplate = () => {
  if (!isServer) return h(Fragment, []);

  return h(Fragment, [
    h("template", {
      shadowrootmode: "open",
      innerHTML: props.html,
    }),
  ]);
};
</script>

<template>
  <component :is="{ render: renderShadowRootTemplate }" />
</template>
Enter fullscreen mode Exit fullscreen mode
<!-- VBottomSheet.vue -->
<template>
  <BottomSheet>
    <ShadowRootTemplate :html="bottomSheetTemplate"></ShadowRootTemplate>
    <slot></slot>
  </BottomSheet>
</template>

<script setup lang="ts">
import { defineComponent, h, onMounted } from "vue";
import { bottomSheetTemplate } from "../web/index.ssr";
import ShadowRootTemplate from "./ShadowRootTemplate.vue";

// Vue wrapper component for the BottomSheet web component so that
// the library users do not need to define bottom-sheet as a custom element
const BottomSheet = defineComponent({
  name: "bottom-sheet",
  setup(_, { attrs, slots }) {
    return () => h("bottom-sheet", attrs, slots);
  },
});

onMounted(() => {
  import("../web/index.client").then(({ BottomSheet }) => {
    if (!customElements.get("bottom-sheet")) {
      customElements.define("bottom-sheet", BottomSheet);
    }
  });
});
</script>
Enter fullscreen mode Exit fullscreen mode

Conclusion

The pure-web-bottom-sheet component demonstrates how modern web can now natively handle UI patterns that previously required complex JavaScript-based logic. By leveraging CSS scroll snap, scroll-driven animations, and other modern web platform features, we can create bottom sheets that:

  • Feel more native and responsive than JavaScript-based solutions
  • Provide better performance by utilizing browser-native scrolling physics
  • Maintain accessibility through standard HTML elements
  • Work across frameworks with minimal integration code
  • Support server-side rendering for optimal initial page load performance

As the web platform evolves, more UI components will transition from complex, JavaScript-heavy solutions to simpler implementations supported by built-in native web features, improving performance and user experience. If you are interested in the future of web UI, also check out the Open UI W3C Community Group, which is actively shaping new native components for the platform.

Give pure-web-bottom-sheet a try, and experience the power of latest web features for yourself!

Top comments (0)