Overlay
An invisible frame around a card. The frame owns where and how big; the card owns what it looks like. Every archetype — centered window, bottom sheet, side drawer, corner panel, anchored popover — is the same class with different channel values, so one surface can morph into another with a plain CSS transition.
Geometry is a reactive box model in JS, projected into CSS custom properties. OverlayBox writes --x/--y (position, via one translate), --w/--h (real size) and --dx/--dy (live drag displacement); CSS transitions those channels. Nothing is clamped or docked in CSS — placement is JS, and it is a pure computation you drive from an effect.
Install
import "elements-kit/ui/styles.css";import "elements-kit/ui/styles/palette/gray.css";import "elements-kit/ui/styles/neutral/gray.css";
import "elements-kit/ui/styles/unset.css";import "elements-kit/ui/card/card.css";import "elements-kit/ui/overlay/index.css"; // geometry — import firstimport "elements-kit/ui/overlay/overlay.css"; // presentation (@imports handle.css)JS is optional, from one entry:
import { OverlayBox, ElementBox, MarginBox, WINDOW_BOX, VIEWPORT_BOX, PositionArea, MutableRegion, anchor_length, Motion, Gestures,} from "elements-kit/ui/overlay";Markup
A <dialog> with unset x-overlay, wrapping a plain card. How you open it decides the modality — there is no attribute for it.
<dialog class="unset x-overlay"> <div class="x-card" data-variant="elevated">Hello.</div> <div class="x-handle" data-placement="move"></div> <div class="x-handle" data-placement="end-end"></div></dialog>The card is the frame’s required direct child; the open frame is a flex column, so the card grows into a definite frame and scrolls below it. Gesture affordances are .x-handle children — siblings of the card, so the card’s clip can’t chop the grip — each carrying a data-placement.
| Opened via | Modality |
|---|---|
dialog.showModal() | Modal — focus trap, inert page, backdrop |
popover + popovertarget | Non-modal top layer, light dismiss |
popover="manual" | Persistent — the page stays interactive |
Channels
OverlayBox sets top: 0; left: 0 and one inline translate on the element, then writes the channels below. The translate composes position, drag displacement and the enter/exit slide, so all three stack without fighting:
translate: calc(--x + --dx + --_ex − --_ox) calc(--y + --dy + --_ey − --_oy)width: var(--w, var(--overlay-w, auto))height: var(--h, var(--overlay-h, auto))| Channel | Written by | Meaning |
|---|---|---|
--x / --y | box.x = / box.y = | Committed position — the point origin lands on |
--w / --h | box.w = / box.h = | Explicit size; NaN removes the property, falling back to --overlay-w/-h then content sizing |
--dx / --dy | box.displacement | Live drag offset, folded into --x/--y by displacement.apply() |
--_ex / --_ey | overlay.css | Enter/exit slide, as a percentage of the box |
--_ox / --_oy | box.origin = | Origin shift, as a percentage of the box |
Authored properties — set these in CSS or inline style:
| Property | Default | Meaning |
|---|---|---|
--overlay-w / --overlay-h | auto | Size fallback when the JS channel is unset |
--overlay-duration | 300ms | Morph + enter/exit duration |
--overlay-easing | cubic-bezier(0.32, 0.72, 0, 1) | iOS-style ease-out |
--overlay-backdrop | --color-overlay | Modal backdrop color |
--overlay-grip-radius | --radius-5 | Corner-grip arc radius — match the card’s data-size |
| Attribute | On | Meaning |
|---|---|---|
data-no-transition | frame | Kills the transition — set it while directly manipulating, remove on release |
data-placed | frame | top / bottom / left / right — the settled side; sets the scale origin for anchored enters |
data-placement | .x-handle | The affordance to paint (see below) |
data-material-background | frame or ancestor | Cards default to solid inside an overlay; translucent opts into frosted glass |
data-overlay-deck | page wrapper | iOS deck effect while a bottom sheet is open |
Changing any channel on an open overlay morphs it — every value is an interpolable length. Position and size interpolate together, and with interpolate-size: allow-keywords a content-sized height morphs against a pinned one.
Boxes
Everything spatial is a box: { x, y, w, h } in viewport coordinates, read through getters that track reactively. Placement is composition over boxes.
| Class | Is |
|---|---|
ElementBox(el) | An element’s live rect (observed). Takes a signal, so the tracked element can be swapped |
WINDOW_BOX | The layout viewport, plus its direction |
VIEWPORT_BOX | The visual viewport — the window minus the software keyboard or pinch-zoom, where it sits in the layout viewport. iOS never resizes the layout viewport for the keyboard, so dock keyboard-adjacent surfaces to this rather than WINDOW_BOX |
MarginBox(box, top?, right?, bottom?, left?) | A box grown per side — the gap off an anchor, spelled as CSS spells it. Sides default like the margin shorthand, and every field is assignable and reactive |
OverlayBox(el) | The surface — reads its measured rect, writes the channels |
ElementBox and OverlayBox own an observer, so dispose them (box[Symbol.dispose](), or overlay.dispose()). Inside an effectScope the effects clean up with the scope.
OverlayBox
Reads are the measured rect (what the element actually is, after transitions and content sizing); writes go to the channels. That asymmetry is deliberate — you place against reality, not against your last write.
const overlay = new OverlayBox(panel);
overlay.x = 120; // → --x: 120px, morphsoverlay.h = NaN; // → unset, back to content sizingoverlay.w; // → the measured width, reactiveorigin names which point of the box lands on (x, y), as a transform-origin pair — and the scale grows from the same point. Unset it is { x: "left", y: "top" }, so the channels place the top-left corner.
overlay.origin = { x: "center", y: "bottom" }; // pin the bottom-centerdisplacement is a second, additive column for live manipulation: write it during a drag (it lands in --dx/--dy, and in the size channels), then apply() folds it into the base in one batch, or clear() drops it.
overlay.displacement.x = 25; // → --dx: 25px, base untouchedoverlay.displacement.apply(); // → --x: 125px, --dx removedRegions
A region is somewhere a box may go: a pin line per axis (or null — free), plus the origin that lands on it. place(box) returns a point and never writes, so you take the channels you want; it never returns a size, so a box larger than its room overflows rather than shrinks, as CSS does.
PositionArea(anchor, area)
The CSS position-area property, reimplemented reactively: the anchor’s four edges tile the plane into a 3×3 grid, and the area names the cell. Accepts any valid position-area value — physical, logical, span-*, self-* — order-independent, RTL-resolved through the anchor’s direction. Invalid or contradictory values (top bottom) resolve to block-end, and the type rejects them at compile time.
const anchor = new ElementBox(trigger);const region = new PositionArea(anchor, "block-end span-inline-end");
effect(() => { const { x, y } = region.place(overlay); overlay.x = x; overlay.y = y;});Both area and anchor are assignable, so re-aiming a menu — or re-pointing it at another trigger — happens in place and the panel glides to the new side. There is no gap parameter, for the same reason CSS has none: the offset off the anchor is the overlay’s own margin, or a MarginBox around the anchor.
MutableRegion(boundary)
A region driven by writes, for gestures — the box grows away from the edge you are not dragging. Assigning an inset pins that edge and releases its opposite; assigning null frees the axis. The Boundary type admits a corner (one edge per axis — a popover) or a side (one edge, the other axis free — a bottom sheet), never both edges of one axis.
const region = new MutableRegion({ bottom: 0 }); // sheet resting at the bottomregion.left = 0; // now pinned bottom-leftregion.left = null; // inline axis free againanchor_length(box, inset, side)
The CSS anchor() function, reimplemented reactively — the JS tier speaks the same words as the native one:
overlay.y = anchor_length(anchor, "top", "bottom"); // ≡ top: anchor(bottom)overlay.x = anchor_length(anchor, "left", "center") - overlay.w / 2;inset is the inset property being computed (physical or logical); side is any of top/bottom/left/right, inside/outside, start/end, self-start/self-end, center, or a number as a fraction of the axis. Horizontal writing modes only: the block axis always starts at the top, and only the inline axis consults direction.
Handles
Each .x-handle is a direct child sibling of the card that both paints an affordance and is its pointer hit-target — the painted pill stays thin while a transparent ::before grows the target to a comfortable minimum. One data-placement is the whole vocabulary.
data-placement | Paints |
|---|---|
block-start / block-end | Horizontal pill at that edge — height drag (sheets) |
inline-start / inline-end | Vertical pill at that edge — width drag (drawers) |
start-start, start-end, end-start, end-end | Corner L-grip, block side first, inline second (windows) |
move | Top-center grab pill (window move) |
The handle also picks the enter/exit motion, since an edge handle implies the opposite edge is docked: a block-start handle slides the frame up from below, inline-end slides it in from the inline-start edge (mirrored in RTL). No edge handle — none, a corner grip, or move only — settles in from a scale instead.
Motion and gestures
Direct manipulation needs velocity, not just position. Motion is one animatable scalar that tracks its own delta and velocity from the timing of its writes:
const m = new Motion(startHeight);m.value = next; // or m.move(delta)m.displacement; // how far from the seed — reactivem.velocity; // px/ms, for a release projectionm.abort(); // snap back to the seedGestures
Gestures is a namespace of pure scalar shapers — a Modifier is number → number, applied at display time while the true value stays in Motion, so a release settles cleanly.
| Function | Does |
|---|---|
rubber(min, max, dimension, constant?) | iOS elastic resistance past the bounds — pull, but never escape |
detent(points, strength?) | Magnetic pull toward the nearest stop during the drag (0 free, 1 snap) |
nearest(value, points) | The closest stop |
snap(value, velocity, points, reach?) | Release target — project by velocity, then take the nearest stop |
import { Motion, Gestures } from "elements-kit/ui/overlay";
const stops = [0.25, 0.6, 0.9].map((f) => f * innerHeight);const h = new Motion(panel.getBoundingClientRect().height);const shape = Gestures.rubber(stops[0], stops.at(-1)!, innerHeight);
panel.dataset.noTransition = "";onpointermove = (e) => { h.value = innerHeight - e.clientY; overlay.h = shape(h.value);};onpointerup = () => { delete panel.dataset.noTransition; // morph to the rested height overlay.h = Gestures.snap(h.value, h.velocity, stops);};Deck effect
Put data-overlay-deck on the page wrapper to scale it back iOS-style while a bottom-docked sheet (a block-start handle) is open. Pure CSS, degrades to nothing without :has().
Browser support
Position and size morphs work everywhere the translate property exists (Chrome 104+). Enter/exit transitions layer on under @supports (transition-behavior: allow-discrete) via @starting-style, and @supports (overlay: auto) keeps the exit inside the top layer (Firefox clips it; entry is unaffected). Anchoring is JS in every browser — PositionArea and anchor_length reimplement the CSS semantics rather than gating on them. prefers-reduced-motion drops every transition, including the deck.