4.1 KiB
Overlays
Popovers, menus, selects, and dialogs are native top layer elements, meaing the browser handles show, dismiss, focus and stacking. However there are some things CSS can't handle perfectly on it's own, like placing a popover next to its anchor, scaling a popover to the available view height, preventing the page from scrolling, and keeping things in view when the mobile keyboard opens. We have three hooks to solve these issues.
Floating layer
useFloatingLayer places a popover next to its anchor. Used by Popover, Menu and Select, but anything new that is anchored should use it too.
useFloatingLayer({
isOpen,
floatingRef: popoverRef,
getAnchor: () => triggerRef.current,
placement: "bottom start",
});
Placements are "top", "bottom", "right", "bottom start" and "bottom end". The side is only a preference. When the content does not fit on one side and the opposite side has more room, the popover flips.
However the hook only handles positioning. For sizing the popover you can use the CSS variables that the hook sets:
| Variable | Usage |
|---|---|
--floating-available-width / --floating-available-height |
Can be used for max-width / max-height on the anchor |
--floating-anchor-width / --floating-anchor-height |
Can be used to match the popover size to the anchor |
--floating-transform-origin |
Can be used for transform-origin for transform animations |
The hook also sets data-side and data-align for styling by placement.
.popover {
position: absolute;
margin: 0;
width: var(--floating-anchor-width);
max-height: var(--floating-available-height, none);
}
The settings for the hook are global CSS properties in vars.css:
| Variable | Meaning |
|---|---|
--floating-gap |
The gap between the popover and its anchor |
--floating-viewport-padding |
The room between the edge of the popover and edge of the viewport |
--popover-boundary-top |
The top nav, the popovers stay below it |
--popover-boundary-bottom |
The mobile nav, the popover stays above it |
Behaviour
- Available room is recalculatedd on every scroll and resize.
- The visible viewport is the boundary, not the entire viewport, so it respects the mobile keyboard as well as the top nav and mobile nav.
- Positioning modes.
position: absolutein most cases,position: fixedif the anchor is inside or is itself a fixed or sticky element. - Hidden when detached. The popover is hidden when the anchor leaves the viewport, this includes being covered by the top nav and mobile nav.
- Nothing opens before hydration. A trigger gets its
popovertargetonly once hydrated so the browser can't open the popover with no logic to place it. A click does nothing until the floating layer is loaded. - The far edge stutters while scrolling. When scrolling the popover resizing can sometimes look a bit laggy/stuttery. This is acceptable, no possible workaround (all UI libs have this).
The calcuations live in floating-layer.ts as pure functions.
Scroll lock
lockScroll() keeps the page from scrolling until the release is called. Locks can be nested. useScrollLock(locked) wraps it for state-driven logic. useScrollLockWhileOpen(ref) lets you follows a native dialogs or popovers open state through its toggle events (this works before hydration). Dialogs, the mobile nav panels and the mobile side nav lock.
The lock goes on body and not html. Because we use body { overflow-x: hidden } in the global styles, a hidden html would stop the bodys overflow from propagating which turns the body into a scroll container, which breaks the sticky header (and other sticky elements). The width that the scrollbar took is added as padding to the body, because of some quirk with Chromiums scrollbar-gutter. A fixed element like the toast or the mobile nav bar should use --scrollbar-width as right side padding.
Scroll into view
useScrollIntoView(isOpen, getAnchor) brings a popovers anchor back into the viewport when a mobile keyboard opens over it. It will not scroll a scroll locked page! Select and Popover use it.