Skip to content

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 first
import "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 viaModality
dialog.showModal()Modal — focus trap, inert page, backdrop
popover + popovertargetNon-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))
ChannelWritten byMeaning
--x / --ybox.x = / box.y =Committed position — the point origin lands on
--w / --hbox.w = / box.h =Explicit size; NaN removes the property, falling back to --overlay-w/-h then content sizing
--dx / --dybox.displacementLive drag offset, folded into --x/--y by displacement.apply()
--_ex / --_eyoverlay.cssEnter/exit slide, as a percentage of the box
--_ox / --_oybox.origin =Origin shift, as a percentage of the box

Authored properties — set these in CSS or inline style:

PropertyDefaultMeaning
--overlay-w / --overlay-hautoSize fallback when the JS channel is unset
--overlay-duration300msMorph + enter/exit duration
--overlay-easingcubic-bezier(0.32, 0.72, 0, 1)iOS-style ease-out
--overlay-backdrop--color-overlayModal backdrop color
--overlay-grip-radius--radius-5Corner-grip arc radius — match the card’s data-size
AttributeOnMeaning
data-no-transitionframeKills the transition — set it while directly manipulating, remove on release
data-placedframetop / bottom / left / right — the settled side; sets the scale origin for anchored enters
data-placement.x-handleThe affordance to paint (see below)
data-material-backgroundframe or ancestorCards default to solid inside an overlay; translucent opts into frosted glass
data-overlay-deckpage wrapperiOS 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.

ClassIs
ElementBox(el)An element’s live rect (observed). Takes a signal, so the tracked element can be swapped
WINDOW_BOXThe layout viewport, plus its direction
VIEWPORT_BOXThe 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, morphs
overlay.h = NaN; // → unset, back to content sizing
overlay.w; // → the measured width, reactive

origin 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-center

displacement 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 untouched
overlay.displacement.apply(); // → --x: 125px, --dx removed

Regions

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 bottom
region.left = 0; // now pinned bottom-left
region.left = null; // inline axis free again

anchor_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-placementPaints
block-start / block-endHorizontal pill at that edge — height drag (sheets)
inline-start / inline-endVertical pill at that edge — width drag (drawers)
start-start, start-end, end-start, end-endCorner L-grip, block side first, inline second (windows)
moveTop-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 — reactive
m.velocity; // px/ms, for a release projection
m.abort(); // snap back to the seed

Gestures

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.

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

See also

  • Card — the surface inside the frame
  • Theming — palettes, materials, light & dark
  • ScopeseffectScope and cleanup
  • Signals — the reactivity the boxes assume