@ax-design/surface

中文文档
0.4.4发布于 2026/10/27MITTypeScriptES Module构建来源已验证近 30 天下载 3040

Web component that implements Surface of Axiom Design System: one shape, one tilt and one frame scheduler for every visual layer.

原始使用文档
文档目录44 节
  1. Installation
  2. Usage
  3. Images and other content in a shape
  4. Keyboard focus
  5. Accessibility preferences
  6. Selecting and dragging
  7. Pressing from code
  8. Moving from code
  9. Easing
  10. Watching style changes
  11. Surfaces inside surfaces
  12. Style Controlling
  13. --ax-radius
  14. --ax-{corner}-radius
  15. --ax-corner-shape
  16. --ax-corner-{corner}-shape
  17. --ax-corner-shoulder-radius, --ax-corner-{corner}-shoulder-…
  18. --ax-corner-{corner}-shift-x, --ax-corner-{corner}-shift-y
  19. --ax-edge-kind, --ax-edge-depth, --ax-edge-span, --ax-edge-…
  20. --ax-edge-{side}-kind
  21. --ax-edge-{side}-depth
  22. --ax-edge-{side}-span
  23. --ax-edge-{side}-at
  24. --ax-edge-{side}-ratio
  25. --ax-edge-{side}-tip-radius, --ax-edge-{side}-shoulder-radi…
  26. --ax-border-width
  27. --ax-border-{side}-width
  28. --ax-tilt-mode
  29. --ax-tilt-depth
  30. --ax-tilt-factor
  31. --ax-tilt-zoom
  32. --ax-tilt-perspective
  33. --ax-tilt-direction
  34. Schema groups
  35. --ax-fill-color
  36. --ax-border-color
  37. --ax-focus-ring-width, --ax-focus-ring-secondary-width
  38. --ax-focus-ring-offset
  39. --ax-focus-ring-color, --ax-focus-ring-secondary-color
  40. Writing a layer
  41. Frames for code that is not a surface
  42. Writing an element
  43. Browser support
  44. Changelog

Ax Surface

Web component that Combine All Layers of Axiom Design System.

npm version CI Status MIT Licence

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): the fill layer turns its fill into Canvas and its border into CanvasText, as the browser does with a background and a border, and the focus rings take CanvasText and Canvas. 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) and setAlwaysUseFallback(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 call setAlwaysUseFallback when 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-span and friends) that set every side or corner at once: each per-side or per-corner property defaults to auto and 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.

版本与发布记录

@ax-design/surface 共发布 17 个版本,首次发布于 2026/10/13;过去 12 个月发布了 17 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.4latest2026/10/2712 天前
0.4.32026/10/2612 天前
0.4.22026/10/2612 天前
0.4.12026/10/2612 天前
0.4.02026/10/2216 天前
0.3.32026/10/2117 天前

关于 @ax-design/surface 的常见问题

@ax-design/surface 是什么?

Web component that implements Surface of Axiom Design System: one shape, one tilt and one frame scheduler for every visual layer.

如何安装 @ax-design/surface?

在项目目录运行 npm install @ax-design/surface,或使用 pnpm add / yarn add / bun add。

@ax-design/surface 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ax-design/surface 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ax-design/surface 可以商用吗?

@ax-design/surface 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ax-design/surface 的下载量有多少?

根据 npm 官方下载统计,@ax-design/surface 在 2026/10/08 – 2026/11/06 这 30 天内被下载 3,040 次,日均约 101 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ax-design/surface 还在维护吗?

最新版本 0.4.4 发布于 2026/10/27(12 天前);至今共发布 17 个版本,其中过去 12 个月发布了 17 个;首次发布于 2026/10/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。