With modern web features like CSS scroll snap, it is now possible to create native-like bottom sheets featuring multiple snap points and nested scrolling – with close to zero JavaScript – by leveraging the browser's own scrolling functionality. This approach delivers the smoothest possible user experience while also making it easy to leverage built-in accessibility support through native HTML native HTML dialog and Popover API. These capabilities inspired pure-web-bottom-sheet, a lightweight, framework-agnostic web component that delivers native-comparable performance and responsive behavior for the bottom sheets.With modern web features like CSS scroll snap, it is now possible to create native-like bottom sheets featuring multiple snap points and nested scrolling – with close to zero JavaScript – by leveraging the browser's own scrolling functionality. This approach delivers the smoothest possible user experience while also making it easy to leverage built-in accessibility support through native HTML native HTML dialog and Popover API. These capabilities inspired pure-web-bottom-sheet, a lightweight, framework-agnostic web component that delivers native-comparable performance and responsive behavior for the bottom sheets.

Build Native-Like Bottom Sheets with CSS Scroll Snap

2025/11/03 22:22

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 + examples

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

https://codepen.io/viliket/pen/ZYQMjzE?embedable=true

You can view the live demo and more examples at 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>

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>

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; }

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>

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>

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: ""; } }

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

    \

  1. 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.

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; } }

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; }

\ 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; } }

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%; } }

\ 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); } }

\ 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)) ); } } }

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; } }

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); }

\ 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>

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>

::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; }

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(); } } } );

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>

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)); } }

\ 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(); } } );

\ 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>

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.

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; } // 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 /> </> ); }

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> <!-- 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>

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!

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact [email protected] for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.
Share Insights

You May Also Like

Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return

Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return

BitcoinWorld Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return Imagine logging into an old account and discovering a fortune! That’s exactly what happened to NBA superstar Kevin Durant. His decade-old, forgotten Coinbase account, which held an early Kevin Durant Bitcoin investment, has now resurfaced, revealing an incredible 195-fold return. This remarkable story highlights the immense potential of long-term cryptocurrency holdings and serves as a fascinating example for anyone interested in digital assets. The Accidental ‘Hodl’: How Kevin Durant’s Bitcoin Investment Skyrocketed The journey of Kevin Durant’s Bitcoin investment began in 2016. He encountered Bitcoin, then priced at a modest $600, during a birthday celebration for venture capitalist Ben Horowitz. Intrigued, Durant decided to invest, setting up a Coinbase account. However, as many early adopters can attest, managing digital assets in the nascent crypto landscape wasn’t always straightforward. Durant subsequently misplaced his Coinbase login credentials, leading to an involuntary long-term hold – a phenomenon affectionately known as "HODL" (Hold On for Dear Life) in the crypto community. This accidental strategy proved to be a stroke of pure luck. After a decade, with assistance from Coinbase and a thorough identity verification process, Durant successfully recovered his account. While the exact amount of BTC remains undisclosed, the outcome is clear: a staggering 195-fold return on his initial investment. Initial Investment: Bitcoin at $600 in 2016. Accidental Strategy: Lost login details led to an unintentional "HODL." Recovery: Coinbase assisted with identity verification. Return: A remarkable 195-fold increase in value. Beyond Personal Gains: Kevin Durant’s Broader Crypto Engagement This isn’t Kevin Durant’s first foray into the world of digital assets, nor is it his only connection to the industry. Long before this incredible recovery, Durant had already demonstrated a positive and forward-thinking stance toward cryptocurrency. His engagement extends beyond just holding assets; he has actively participated in the crypto ecosystem. Durant previously partnered with Coinbase, one of the leading cryptocurrency exchanges, showcasing his belief in the platform and the broader potential of digital currencies. He has also ventured into the realm of Non-Fungible Tokens (NFTs), purchasing digital collectibles and exploring this evolving sector. These actions underscore his understanding and acceptance of crypto’s growing influence. His continued involvement helps bridge the gap between mainstream culture and the crypto world, bringing increased visibility and legitimacy to digital assets. The story of his Kevin Durant Bitcoin recovery only adds another layer to his impressive crypto narrative, inspiring many to consider the long-term prospects of digital investments. Valuable Lessons from Kevin Durant’s Bitcoin Journey Kevin Durant’s story offers compelling insights for both seasoned investors and newcomers to the crypto space. It powerfully illustrates the potential rewards of a patient, long-term investment approach, even if accidental. While not everyone will forget their login details for a decade, the principle of "HODLing" through market volatility can yield significant returns. However, it also subtly highlights the importance of proper security and record-keeping. Losing access to an account, even if eventually recovered, can be a stressful experience. Here are some actionable takeaways: Embrace Long-Term Vision: Bitcoin’s history shows substantial growth over extended periods. Patience often outperforms short-term trading. Secure Your Assets: Always keep your login details, seed phrases, and recovery information in multiple, secure locations. Consider hardware wallets for significant holdings. Understand the Volatility: Crypto markets are volatile. Investing only what you can afford to lose and being prepared for price swings is crucial. Stay Informed: While Durant’s hold was accidental, continuous learning about the crypto market can help make informed decisions. His experience reinforces the idea that strategic, even if involuntary, patience can be profoundly rewarding in the world of cryptocurrency. The Kevin Durant Bitcoin story is a testament to this. The tale of Kevin Durant’s forgotten Coinbase account and his astonishing 195-fold return on a decade-old Bitcoin investment is nothing short of extraordinary. It’s a vivid reminder of the transformative power of early adoption and the incredible growth potential within the cryptocurrency market. Beyond the personal windfall, Durant’s continued engagement with crypto, from partnerships to NFTs, reinforces his role as a prominent figure in the digital asset space. His accidental "HODL" has become a legendary example, inspiring many to look at long-term crypto investments with renewed optimism and a keen eye on future possibilities. Frequently Asked Questions About Kevin Durant’s Bitcoin Investment Here are some common questions regarding Kevin Durant’s recent crypto revelation: Q: How much did Kevin Durant initially invest in Bitcoin?A: The exact amount of Bitcoin Kevin Durant initially invested has not been disclosed. However, it was purchased around 2016 when Bitcoin was priced at approximately $600. Q: How did Kevin Durant recover his forgotten Coinbase account?A: Coinbase assisted Kevin Durant in recovering his account after he completed a thorough identity verification process, confirming his ownership of the decade-old account. Q: What does "195-fold return" mean?A: A "195-fold return" means that the value of his initial investment multiplied by 195 times. If he invested $1,000, it would now be worth $195,000. Q: Has Kevin Durant invested in other cryptocurrencies or NFTs?A: Yes, Kevin Durant has shown a friendly stance toward cryptocurrency beyond Bitcoin. He has partnered with Coinbase and has also purchased Non-Fungible Tokens (NFTs) in the past. Q: Is Kevin Durant’s story typical for Bitcoin investors?A: While the 195-fold return is exceptional, the principle of significant gains from long-term holding (HODLing) is a common theme in Bitcoin’s history. However, not all investments yield such high returns, and market volatility is always a factor. Did Kevin Durant’s incredible crypto journey inspire you? Share this astonishing story with your friends and followers on social media to spark conversations about the future of digital assets and the power of long-term investing! Your shares help us bring more fascinating crypto news to a wider audience. To learn more about the latest Bitcoin trends, explore our article on key developments shaping Bitcoin’s institutional adoption. This post Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return first appeared on BitcoinWorld.
Share
Coinstats2025/09/19 18:45