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,
PositionTry,
MutableArea,
Align,
place,
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)a soft 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

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

Every box class is also a region — it has xmin, xmax, ymin and ymax — so you can pass it wherever a container or bound is expected.

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), per axis, as an Align — and the scale grows from the same point. Unset it is { x: 0, y: 0 }, so the channels place the top-left corner.

overlay.origin = { x: Align.center, y: Align.end }; // 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

Areas

An area is somewhere a box may go: a region’s edges plus where the box sits between them. You place a box in one without measuring it — place(overlay, area) sets its origin to the area’s alignment and moves that point onto the area’s line.

// Region: edges as viewport coordinates, y down. undefined = open.
interface Region { xmin?: number; xmax?: number; ymin?: number; ymax?: number }
// Area: a region, and the box point that sits on each axis. undefined = free.
interface Area extends Region { xalign?: Align; yalign?: Align }
// Align: 0 start edge … 1 end edge — CSS's 0% … 100%.
Align.start; // 0
Align.center; // 0.5
Align.end; // 1

Edges are coordinates, never insets: ymax is where the bottom edge is, not how far it is from anything.

place(overlay, area)

Per axis, the aligned point lands on the area’s line: min at 0, max at 1, min + (max − min) · align between. OverlayBox shifts itself back by align × its own size in CSS, so the box lands without being measured. A free axis — no align, or a centre without both edges — is left alone.

effect(() => place(overlay, area));

A box larger than its room overflows rather than shrinks, as in CSS.

PositionArea(anchor, area, container?)

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 area = new PositionArea(anchor, "block-end span-inline-end");

The container is the containing block — the window unless you pass one. It only limits the room: the edge the box hugs is always the anchor’s, so the box follows the anchor as it scrolls. A centred axis is a room symmetric around the anchor’s middle, out to the container’s nearer edge.

area, anchor and container 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.

area.intersect(...regions)

intersect cuts the area by other regions and keeps its alignment. Cut by its own container, the anchor side stops at the container’s edge instead of following the anchor out — a menu that sticks to its scroller.

const scroller = new ElementBox(list);
const below = new PositionArea(anchor, "block-end", scroller);
below; // follows the anchor out of the scroller
below.intersect(below.container); // stops at the scroller's edge

The result is live. With no shared room it keeps its own crossed edges, so PositionTry passes it over.

box.toArea(xalign?, yalign?)

Any box becomes a live area: its edges, plus the box point that sits on each axis — the top-left corner by default, as origin. It has the same intersect.

// A bottom sheet, docked above the keyboard.
place(sheet, VIEWPORT_BOX.toArea(Align.center, Align.end));
// Centred in the page column, under a header.
column.toArea(Align.center, Align.start).intersect({ ymin: 64 });

PositionTry(subject, candidates, order?)

The CSS position-try-fallbacks property: the first candidate with room for subject — usually the overlay itself. It is an area, so you place with it as with any other. When none fits it keeps the first, as CSS does, and fits turns false.

const below = new PositionArea(anchor, "block-end", scroller);
const above = new PositionArea(anchor, "block-start", scroller);
const tried = new PositionTry(overlay, [below, above]);

Each candidate carries its own container, so a menu can keep inside a scroller while it can and then spill into the window — a fallback CSS cannot express, since every position-try option shares one containing block. order ranks candidates first, as position-try-order does: "normal", "most-width" or "most-height".

MutableArea(area?)

An area you write, every field reactive — a sheet whose edge follows the keyboard, or a panel a gesture resizes.

const sheet = new MutableArea({ ymax: VIEWPORT_BOX.ymax, yalign: Align.end });
effect(() => {
sheet.ymax = VIEWPORT_BOX.ymax; // rests on the visual viewport's bottom
});

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

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, PositionTry and anchor_length reimplement the CSS semantics rather than gating on them. prefers-reduced-motion drops every transition.

See also

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