Web component that Combine All Layers of Axiom Design System.
Installation
// with npm
npm install @ax-design/surface
// with bun
bun add @ax-design/surface
and import it:
import { register } from '@ax-design/surface';
register();
Usage
A surface is the box that every visual effect of Axiom Design System draws on. It owns the shape, the tilt and one frame scheduler for the whole page, and it draws its content with layers.
To give your element a shape, wrap it with ax-surface and pick the layers you need.
Here's an example:
<ax-surface layers="fill">
<button class="my-button">Hello</button>
</ax-surface>
ax-surface {
--ax-radius: 12px;
--ax-fill-color: white;
--ax-border-color: #3b4252;
}
Layers are drawn in the order you list them, and your content is always on top. fill comes with this package, other packages add more: elevation from @ax-design/elevation (a shadow that follows the shape), reveal from @ax-design/reveal-highlight and acrylic from @ax-design/acrylic:
<ax-surface layers="elevation fill reveal">
<button class="my-button">Hello</button>
</ax-surface>
Want to try the properties? Run bun install && bun run demo in this repository and open the page it prints. It has a playground, shapes after the Material 3 shape set and a few tiles.
Surfaces only work while they are visible, read their styles only after something changed, and lower the frame rate of far and small surfaces when the page starts dropping frames.
Images and other content in a shape
Add the clip attribute and the content of the surface is cut to its shape, so an image or a video fills a tile of any shape. The cut follows the shape when it changes, and tilts with the content when the surface is pressed:
<ax-surface clip layers="reveal" style="--ax-radius: 24px">
<img src="cover.jpg" alt="" style="display: block; width: 100%; height: 100%; object-fit: cover">
</ax-surface>
Keyboard focus
Add the focus layer and the surface draws the focus visual of Windows around its shape while it, or an element in it, has keyboard focus (:focus-visible): a light 1 px ring against the shape and a dark 2 px ring around it. Unlike outline, the rings follow every corner and edge of the shape, and the clip attribute does not cut them.
<ax-surface layers="fill reveal focus">
<button class="my-button">Hello</button>
</ax-surface>
/* The surface draws the ring now; keep the browser's own outline for the keyboard off. */
ax-surface .my-button:focus-visible { outline: none; }
The rings are drawn outside the element, so an ancestor with overflow: hidden cuts them like it cuts an outline. Change them with the --ax-focus-ring-* properties.
Accessibility preferences
Surfaces follow the display preferences of the user, as the effects of Windows do:
- Forced colors (Windows High Contrast,
forced-colors: active): thefilllayer turns its fill intoCanvasand its border intoCanvasText, as the browser does with a background and a border, and the focus rings takeCanvasTextandCanvas. SVG and canvas drawings are not recolored by the browser, so every layer handles this itself. - Reduced motion (
prefers-reduced-motion: reduce): the tilt takes its pose at once instead of easing into it. The surface still tilts, as Windows still plays the pressed state when animations are off; only the transition is skipped. - Reduced transparency (
prefers-reduced-transparency: reduce) andsetAlwaysUseFallback(true): effects with a solid fallback, such as acrylic and reveal, use it. A page can't detect battery saver or a slow device, the other times Windows falls back, so callsetAlwaysUseFallbackwhen you know better:
import { setAlwaysUseFallback } from '@ax-design/surface';
setAlwaysUseFallback(true);
- Higher contrast (
prefers-contrast: more, for example macOS "Increase contrast"): surfaces don't change by themselves, as Windows has no such setting. Raise the contrast of the border and of the focus rings yourself:
@media (prefers-contrast: more) {
ax-surface {
--ax-border-color: rgb(0, 0, 0);
--ax-focus-ring-color: rgb(0, 0, 0);
--ax-focus-ring-secondary-color: rgb(255, 255, 255);
}
}
Selecting and dragging
A surface is a control, so pressing or dragging on it does not select its text, and the browser does not drag the links and images in it away (nor show the long-press callout of iOS). Give an element in it draggable="true" to drag it with the drag and drop of the browser; set user-select: text on the surface to make its text selectable again.
Pressing from code
press() presses the surface as a pointer press does, for a keyboard activation: the tilt leans toward the point and the layers see a pressed pointer. Without an argument, or with 'center', it presses the center of the layout box (pointerType is 'keyboard'); with { x, y }, local coordinates in px from the top-left of the surface before its tilt, it presses that point ('api'). A pointer press in progress wins over it.
release() ends the press: the tilt goes back and the layers see pressed: false, with the point kept so that a release effect can play. It also ends a pointer press before the pointer is up, for a container that takes the gesture over (a tile drag). After such an early release, pressTakenOver is true until the next press starts, so that the click after a drag can be swallowed.
pressRemaining() returns the milliseconds until the press feedback has finished: the longest of the layers' pressRemaining(now) and the rest of the tilt tween (0 with reduced motion). Read it after a frame has drawn the press, as the tween starts with that frame:
surface.press();
requestAnimationFrame(() => setTimeout(activate, surface.pressRemaining()));
Moving from code
motion(state) moves the surface in 3D and fades it, for one frame: call it on every frame of your animation with that frame's state, and with null to end it. rotateX / rotateY (degrees), translateZ (px) and perspective (px, default 1000) turn the surface around originX / originY (local px, default its center; the origin may lie outside the surface, as for a row of tiles turning around the left edge of their group), on top of its press tilt. scale (default 1) and x / y (px) enlarge it about the origin and move it outside the perspective, as a picture: an element turned edge-on stays a line wherever it is moved. opacity (0 to 1) fades it.
const start = performance.now();
const frame = (now) => {
const t = Math.min(1, (now - start) / 600);
surface.motion({ rotateY: 90 * (1 - t), originX: -surface.offsetLeft, opacity: t });
if (t < 1) requestAnimationFrame(frame);
else surface.motion(null);
};
requestAnimationFrame(frame);
Nothing is written on an ancestor of the layers: the layers draw the motion themselves, so acrylic keeps its backdrop (an opacity on an ancestor makes it the backdrop root in every browser). In Firefox, where no 3D transform may sit above acrylic, the layers draw the projected shape in flat containers during a 3D motion, the content is hidden, and it fades back in once the motion ends.
Easing
windowsEasing(id) gives the timing functions of the Windows 8 and 8.1 animation theme as CSS (windowsEasing(54) is the Start screen coming in, 'cubic-bezier(0, 0.5, 0.5, 1)'); WINDOWS_EASINGS holds all twenty. For animations scripted with requestAnimationFrame, parseEasing(value) reads a CSS timing function (keywords and cubic-bezier()) and bezierAt(curve, t) gives its progress at t.
Watching style changes
A surface notices style changes caused by classes, attributes, :hover, :active, :focus, form states, media queries, stylesheets and your own transitions. If a style of yours depends on something else (for example an element inside another component's shadow root), use observe="precise":
<ax-surface layers="fill" observe="precise">...</ax-surface>
or call element.refresh() (one surface) or refreshAll() (every surface) after the change.
Surfaces inside surfaces
A surface inside a tilted surface (for example ax-reveal inside ax-pressure) follows the tilt: your pointer is mapped through every tilt above it, so its effects stay under your pointer.
Style Controlling
Surface component uses custom properties to manage its shape. In the names below, {corner} is one of top-left, top-right, bottom-right, bottom-left, and {side} is one of top, right, bottom, left.
Shape properties are not inherited, set them on the ax-surface element itself.
--ax-radius
Type: <length-percentage>
Default: 0px
Description: The radius of every corner. Percentages are relative to the shorter side. Like border-radius, radii that do not fit are scaled down.
--ax-{corner}-radius
Type: <length-percentage>
Default: -1px
Description: The radius of one corner. A negative value means "use --ax-radius".
--ax-corner-shape
Type: round | bevel | scoop | notch
Default: round
Description: The shape of every corner, with the same meaning as the CSS corner-shape keywords.
--ax-corner-{corner}-shape
Type: round | bevel | scoop | notch | auto
Default: auto
Description: The shape of one corner. auto means "use --ax-corner-shape".
--ax-corner-shoulder-radius, --ax-corner-{corner}-shoulder-radius
Type: <length>; <length> | auto
Default: 0px; auto
Description: Rounds the two points where a bevel, scoop or notch corner meets the sides. Each corner defaults to auto, which means "use --ax-corner-shoulder-radius". round corners ignore it. The general property sets every corner unless a corner sets its own. With --ax-radius: 50% and scoop, neighboring corners meet in the middle of each side, and the shoulder radius rounds those points into a soft star; very large values turn it into a circle.
--ax-corner-{corner}-shift-x, --ax-corner-{corner}-shift-y
Type: <length-percentage>
Default: 0px
Description: Moves one corner point into the shape, percentages are relative to the width (x) or the height (y). Shift the top left and bottom right corners on x and you get a parallelogram. Negative values move the corner out of the box.
--ax-edge-kind, --ax-edge-depth, --ax-edge-span, --ax-edge-at, --ax-edge-ratio, --ax-edge-tip-radius, --ax-edge-shoulder-radius
Description: The feature of every side at once. Each side property below defaults to auto, which means "use the general property of the same name", so --ax-edge-depth sets --ax-edge-top-depth, --ax-edge-right-depth and so on, unless a side sets its own. Transitions and animations of these properties move all four sides.
.cookie {
--ax-radius: 30%;
--ax-edge-kind: cusp;
--ax-edge-depth: -6px;
--ax-edge-span: 100%;
--ax-edge-tip-radius: 20px;
/* The top side goes deeper than the others. */
--ax-edge-top-depth: -12px;
}
Sides run clockwise (see --ax-edge-{side}-at), so one --ax-edge-at away from 50% turns every feature the same way around the shape: the result has rotational symmetry, not mirror symmetry.
--ax-edge-{side}-kind
Type: flat | arc | rect | triangle | trapezoid | cusp | auto
Default: auto
Description: The feature on one side. auto means "use --ax-edge-kind". arc is a circular bump or notch, rect, triangle and trapezoid are straight ones. cusp is two arcs that leave the side smoothly at both ends of the span and meet in a point: inward it pinches the side (the waist of the Material bun, the gaps of the clover), outward it makes a pointed arch. It needs no shoulder radius, and tip-radius rounds the point.
--ax-edge-{side}-depth
Type: <length> | auto
Default: auto
Description: How far the feature goes, positive values go out of the shape, negative values go into it. auto means "use --ax-edge-depth".
--ax-edge-{side}-span
Type: <length-percentage> | auto
Default: auto
Description: The width of the feature along the side, percentages are relative to the length of the side. auto means "use --ax-edge-span".
--ax-edge-{side}-at
Type: <length-percentage> | auto
Default: auto
Description: Where the middle of the feature sits along the side. Sides run clockwise: top from left to right, right from top to bottom, bottom from right to left and left from bottom to top. So --ax-edge-bottom-at: 20% is near the bottom right corner. auto means "use --ax-edge-at".
--ax-edge-{side}-ratio
Type: <number> | auto
Default: auto
Description: For trapezoid only, the width of the far end compared to the base. 1 gives a rectangle, 0 a triangle, and animating it morphs one into the other. auto means "use --ax-edge-ratio".
--ax-edge-{side}-tip-radius, --ax-edge-{side}-shoulder-radius
Type: <length> | auto
Default: auto
Description: Rounds the far corners (tip) and the corners where the feature meets the side (shoulder). auto means "use --ax-edge-tip-radius" or "use --ax-edge-shoulder-radius".
--ax-border-width
Type: <length>
Default: 1px
Description: The width of the border on every side. A surface keeps its border equally thick on screen while it is tilted.
--ax-border-{side}-width
Type: <length>
Default: -1px
Description: The width of the border on one side. A negative value means "use --ax-border-width".
--ax-tilt-mode
Type: off | absolute | relative
Default: off
Description: Tilts the surface toward the point you press. absolute sinks the pressed side by --ax-tilt-depth, relative tilts it by --ax-tilt-factor degrees.
--ax-tilt-depth
Type: <length>
Default: 1px
Description: The depth at which one side sinks down when you press the edge of the surface.
--ax-tilt-factor
Type: <number>
Default: 2
Description: The angle in degrees for relative mode.
--ax-tilt-zoom
Type: <length>
Default: 0.8px
Description: The extent to which the whole surface falls down when you press its center.
--ax-tilt-perspective
Type: <length>
Default: 25px
Description: The perspective of the tilt, check out this tutorial.
--ax-tilt-direction
Type: normal | inverted
Default: normal
Description: inverted lifts the pressed side instead of sinking it.
Schema groups
The properties above come in three groups, and a component that needs only one of them does not have to take the rest. Each group is exported as a bundle:
import { SHAPE } from '@ax-design/surface';
// or, without pulling the element, its scheduler and its layers into the build:
import { SHAPE } from '@ax-design/surface/schema';
SHAPE.read- the shape properties, read on every paint. This includes the general properties (--ax-edge-spanand friends) that set every side or corner at once: each per-side or per-corner property defaults toautoand the surface reads the general value when it does.BORDER.read,TILT.read- the border and the tilt properties. They have no general counterpart.
CORE_SCHEMA is the three read lists in that order, unchanged, for consumers and layers that
want the whole contract.
--ax-fill-color
Type: <color>
Default: transparent
Description: For the fill layer, the color inside the shape.
--ax-border-color
Type: <color>
Default: transparent
Description: For the fill layer, the color of the border.
--ax-focus-ring-width, --ax-focus-ring-secondary-width
Type: <length>
Default: 2px, 1px
Description: For the focus layer, the width of the outer (primary) ring and of the inner (secondary) ring.
--ax-focus-ring-offset
Type: <length>
Default: 0px
Description: For the focus layer, the gap between the shape and the inner ring.
--ax-focus-ring-color, --ax-focus-ring-secondary-color
Type: <color>
Default: black, rgba(255, 255, 255, 0.6)
Description: For the focus layer, the colors of the outer and the inner ring. Set them for a dark theme; in forced colors mode they are CanvasText and Canvas.
Writing a layer
A layer is an object that draws one thing on the surface. Register it with a name, then use the name in the layers attribute:
import { registerLayer } from '@ax-design/surface';
registerLayer('outline', () => ({
attach(container) { /* create your canvas or SVG in container */ },
detach() {},
write(frame) {
// frame.shape.outline, frame.pointer, frame.values, frame.changed ...
return false; // true asks for another frame
},
}));
Layers draw inside the paint box (frame.paint): the element's box, grown to fit features that go out of the shape. If your layer paints outside the shape (a glow, a shadow), tell the surface how far with overflow, in px, for every side or as [top, right, bottom, left]:
registerLayer('glow', () => ({
overflow(values, width, height) {
return 16;
},
// ...
}));
A paint box larger than the element overlaps its neighbors, and the browser may then have to composite them separately. Ask only for what you draw.
frame.appearance holds the user's preferences (forcedColors, reducedTransparency, reducedMotion, alwaysUseFallback), and frame.changed.appearance is set on the first frame after one of them changed, even when no style value did. Draw with CSS system colors in forced colors mode, or not at all: the browser does not recolor SVG paint or canvas pixels. useFallback(frame.appearance) tells whether an effect with a solid fallback should use it. Outside a layer, appearance() returns the same state.
frame.opacity is the opacity of the surface's motion: apply it to your own drawing (the opacity of your canvas or SVG), never to an ancestor. frame.tilt is the press tilt and the motion together. When frame.projected is true (Firefox during a 3D motion), your container carries no transform: draw the shape through frame.tilt yourself, with projectContour(frame.shape.outline, frame.tilt) of @ax-design/shape and apply(frame.tilt, p) for the points of frame.ringInner(); frame.paint then grows to hold the projected shape. frame.changed.motion is set on a frame whose motion changed.
frame.pointer.pressed is also true while press() holds the surface, with pointerType 'keyboard' or 'api'. Press feedback that takes time, such as a ripple, can say how long it still plays with pressRemaining(now), the milliseconds from now (performance.now()) until it has finished. The surface takes the longest of its layers. Layers without the method count as finished.
While the surface is tilted, it puts the tilt on each layer's container. If your layer uses backdrop-filter, set tiltsItself: true and apply frame.tiltTransform to your own elements, around the center of the element's box: inside position: sticky, Firefox renders a backdrop-filter under a transformed ancestor with the wrong backdrop.
Frames for code that is not a surface
The scheduler is one for the whole page, and any object with read, compute, write and priority can be its client: ask with services().scheduler.request(client) and it runs in the next frame with every surface, reads first, then the math, then the writes, so no client reads after another one has written. A client asks again from its own write while it moves, and does not when it is at rest, so an idle page asks for no frame at all. PRIORITY_PRESSED, PRIORITY_POINTER and PRIORITY_IDLE are the priorities the surfaces use; any number between them is a score.
import { PRIORITY_IDLE, services } from '@ax-design/surface';
import type { FrameClient } from '@ax-design/surface';
const client: FrameClient = {
read: () => { /* measure */ },
compute: () => { /* math */ },
write: () => { /* change the DOM */ },
priority: () => PRIORITY_IDLE,
};
services().scheduler.request(client);
A component that has many surfaces inside its own box can have the page watch the visibility of that one box: override visibilityTarget() of the surface class to return the element. A surface that is known to show nothing for a while can be paused (surface.paused = true): it then runs no frame and reads no style, as one outside the viewport, until it is set back to false.
Writing an element
Subclass AxSurface to make an element with its own property names. layerNames_() picks the layers, shapeFor() and borderWidthsFor() read the shape, and tiltParams() and tiltPerspective() read the tilt:
import { AxSurface } from '@ax-design/surface';
class MyCard extends AxSurface {
static schemaKey = 'my-card';
static extraSchema = [{ name: '--my-card-lean', syntax: { type: 'number' }, initial: '3', inherits: true }];
layerNames_() { return ['fill']; }
tiltParams(values, width, height, x, y) {
const lean = values.number('--my-card-lean');
return { width, height, perspective: 400, rotateX: (0.5 - y / height) * lean, rotateY: (x / width - 0.5) * lean, translateZ: 0 };
}
}
customElements.define('my-card', MyCard);
A surface's own style is one constructed sheet that every surface adopts. A class that styles its shadow root with a sheet of its own returns it from static extraSheets(), which is asked once per class and whose sheets come after Surface's (a rule of yours beats one of Surface's of the same specificity). Do not add a style element to each instance: a page of hundreds of surfaces would parse and match one for each. (A browser without constructable style sheets, such as Safari before 16.4, has no adopted sheets, and its surfaces get Surface's style element; add yours to the instance then.)
Browser support
Several packages may each bundle their own copy of Surface (the build/ files of @ax-design/reveal-highlight and @ax-design/acrylic do), and they share their layers and their scheduler on the page.
The copy that loads first defines <ax-surface>. Newer properties, such as the general --ax-edge-* properties, only work when that copy knows them, so keep the copies on the page at the same version.
Surface works in browsers released since 2018 (Chrome 70, Firefox 63, Safari 12). It needs ResizeObserver, IntersectionObserver and Pointer Events, and tells you in the console when one of them is missing so that you can load a polyfill.
Changelog
See CHANGELOG.md.