The shape language of Axiom: a rectangle with four corners and one feature per side, described as numbers and resolved into lines and arcs.
A shape is a rectangle with decorations. Its four corners can be round, beveled, scooped, notched, and they can move into the box; each side can carry one feature, a bump or a notch. You describe all of that as numbers, and resolve turns the description into an outline of straight lines and circular arcs.
This is the geometry under every surface. @ax-design/surface reads the same values from its --ax-* custom properties and calls the library for you, so most of the time you write only CSS; the examples here use the library directly, so you can see what each value does to the outline, and the last one animates a surface with the same values written as CSS. The Playground has every property with live controls.
Shape has no DOM dependency. The same outline becomes an SVG path, traces on a canvas, or computes a border ring and a tilt on the server.
Installation
npm install @ax-design/shapeand import it:
import { resolve, toSvgPath, traceContour, borderInner, tiltHomography } from '@ax-design/shape';From numbers to an outline
You hand resolve a box, four corners and four sides. It answers with an outline: a closed path of line and arc segments. The dotted frame in the preview is the sharp contour before any rounding, with a dot at every vertex; the white shape is what the outline draws.
Nothing is measured from the page, so the same call runs anywhere: toSvgPath writes the contour as an SVG path, traceContour traces it on a canvas context or a Path2D.
Try it
import { resolve, toSvgPath, traceContour } from '@ax-design/shape';
const corner = { radius: 30, shape: 'scoop', shiftX: 0, shiftY: 0 };
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const shape = resolve({
width: 200,
height: 140,
corners: [corner, corner, corner, corner],
edges: [flat, flat, flat, flat],
});
toSvgPath(shape.outline); // SVG
traceContour(ctx, shape.outline); // Canvas.shape {
--ax-radius: 30px;
--ax-corner-shape: scoop;
}Corners
The radius is a fillet: a circle that touches both sides, so the corner leaves them smoothly. The library takes px; Surface also accepts percentages, measured against the shorter side. Like border-radius, radii that do not fit are scaled down rather than overflowing.
The corner shape draws a different curve between those two same tangent points. round is the fillet, bevel cuts straight across, scoop bends the other way and notch cuts in with sharp points. Because the tangent points stay put, changing the shape never changes where the corner meets the sides.
Try it
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const corner = { radius: 34, shape: 'round', shiftX: 0, shiftY: 0 };
resolve({ width: 200, height: 140, corners: [corner, corner, corner, corner], edges: [flat, flat, flat, flat] });.shape {
--ax-radius: 34px;
--ax-corner-shape: round;
}Shoulders
A bevel, scoop or notch meets the side in a point, so the corner enters the side at an angle. The shoulder radius rounds that meeting: the corner leaves the side smoothly, as a fillet would. A round corner is already smooth and ignores it.
Turn the radius up to half the side and the scoop corners of a square meet in the middle of every edge. Rounding those four points turns the square into a soft star; a large enough shoulder closes the star into a circle.
Try it
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const corner = { radius: 90, shape: 'scoop', shiftX: 0, shiftY: 0, shoulderRadius: 12 };
resolve({ width: 180, height: 180, corners: [corner, corner, corner, corner], edges: [flat, flat, flat, flat] });.shape {
--ax-radius: 90px;
--ax-corner-shape: scoop;
--ax-corner-shoulder-radius: 12px;
}Moved corners
shiftX and shiftY move a corner point: positive values point into the shape, negative values out of it. Every corner moves on its own, and the two sides that meet there follow it.
Move the top left and the bottom right corner the same way and the rectangle slants into a parallelogram. Move all four toward the middle for a diamond, or only the two top corners for a trapezoid. Two corners that land on the same point merge into one.
Try it
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const corner = (shiftX, shiftY) => ({ radius: 12, shape: 'round', shiftX, shiftY });
resolve({
width: 200,
height: 140,
corners: [
corner(36, 0), // top left
corner(0, 0), // top right
corner(36, 0), // bottom right
corner(0, 0), // bottom left
],
edges: [flat, flat, flat, flat],
});.shape {
--ax-corner-top-left-shift-x: 36px;
--ax-corner-top-left-shift-y: 0px;
--ax-corner-bottom-right-shift-x: 36px;
}Side features
Each side can carry one feature. kind picks what it is: arc is a circular bump or notch, rect, triangle and trapezoid are straight, and cusp is the two-arc one on the next column. depth says how far it goes, positive out of the shape and negative into it. span is the width of its base in px, and at places its middle along the side, from 0 to 1.
Sides run clockwise: top, right, bottom, left. Put the same feature on every side and the same at turns each one the same way around the box, so the result has rotational symmetry rather than mirror symmetry.
Try it
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const feature = { kind: 'arc', depth: -24, span: 90, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
resolve({
width: 200,
height: 140,
corners: [corner, corner, corner, corner],
// top, right, bottom, left
edges: [feature, flat, flat, flat],
});.shape {
--ax-edge-top-kind: arc;
--ax-edge-top-depth: -24px;
--ax-edge-top-span: 90px;
--ax-edge-top-at: calc(0.5 * 100%);
}The cusp
A cusp is two arcs, each leaving the side smoothly at one end of the span, meeting in a point. It is the one kind that needs no shoulder radius: the arcs already leave the side tangentially.
Pointing into the shape it pinches the side, like the waist of the bun or the gaps between the leaves of a clover. Pointing out it makes a pointed arch. The tip radius rounds the meeting point, and at zero the point stays sharp.
Try it
const flat = { kind: 'flat', depth: 0, span: 0, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const cusp = { kind: 'cusp', depth: -22, span: 180, at: 0.5, ratio: 0.5, tipRadius: 10, shoulderRadius: 0 };
resolve({
width: 180,
height: 160,
corners: [corner, corner, corner, corner],
edges: [flat, cusp, flat, cusp], // top, right, bottom, left
});.shape {
--ax-edge-right-kind: cusp;
--ax-edge-right-depth: -22px;
--ax-edge-right-span: 180px;
--ax-edge-right-tip-radius: 10px;
}When values do not fit
A radius larger than the side, a feature wider than the space left between two corners: none of it overflows or throws. Values are scaled down until they fit, the same way CSS does with border-radius, and a corner and the feature next to it share the straight part of the side and shrink together.
shape.clamps lists every value that was scaled and by how much, so a tool can tell the reader what it had to do. Push the radius and the span until the readout fills up.
Try it
const feature = { kind: 'arc', depth: -20, span: 90, at: 0.5, ratio: 0.5, tipRadius: 0, shoulderRadius: 0 };
const shape = resolve({
width: 200,
height: 70,
corners: [corner, corner, corner, corner],
edges: [feature, flat, flat, flat],
});
shape.clamps; // [{ kind: 'corner-radius', index: 1, factor: 0.62 }, ...].shape {
--ax-radius: 24px;
--ax-edge-top-span: 90px;
}Borders
A border is a ring: the outline, and an inner edge at the same distance inside. borderInner offsets every segment by its own width, joins neighbours where the offset lines cross, and drops segments that turned around in a narrow place, so a ring works on a shape of any complexity. Fill the two contours with the even-odd rule and the ring is what stays between them.
The widths are per side, so one side can be thicker than the others. Pass the tilt of the element and the width is kept as it appears on screen (next column).
Try it
import { borderInner, traceInner } from '@ax-design/shape';
const { inner } = borderInner(shape.outline, { widths: [6, 6, 6, 6] });
ctx.beginPath();
traceContour(ctx, shape.outline);
traceInner(ctx, inner);
ctx.fill('evenodd');.shape {
--ax-border-width: 6px;
--ax-radius: 30px;
}Tilt
When the element is tilted with perspective() and rotateX() / rotateY(), a border taken from the untilted outline would thin on the far side. Pass the tilt as a homography and borderInner offsets in screen space instead: the inner edge comes back in local coordinates, so the ring keeps the same thickness everywhere on screen.
Both shapes below share one outline and one homography. The left one ignored the tilt, the right one passed it. The readout is the thickness borderInner reports for the right one.
Try it
import { borderInner, tiltHomography } from '@ax-design/shape';
const transform = tiltHomography({
width: 140,
height: 100,
perspective: 400,
rotateX: 18,
rotateY: 30,
translateZ: 0,
});
const { inner, thickness } = borderInner(shape.outline, { widths: [8, 8, 8, 8], transform });
// thickness: the same on every side, as seen on screenA shape in motion
A shape is numbers, so a shape can move. Give the properties a transition or an animation and the outline follows: Surface registers every shape property with CSS.registerProperty, so the browser interpolates the numbers. Without registration, an animation of a custom property is applied in steps, and the shape would jump from one value to the next.
The keyframes below move the radius, the four shifts and the shared edge depth together, so a circle becomes a square, then a diamond, then a star and back. A frame whose values do not fit is scaled down on the way, exactly like a shape at rest, so an animation cannot break the outline.
Each frame resolves the shape again. The same call runs whether the numbers came from a stylesheet, from a spring or from your own code.
Try it
<ax-surface class="morph" layers="fill"></ax-surface>.morph {
animation: shape-morph 8s cubic-bezier(0.6, 0, 0.3, 1) infinite;
}
@keyframes shape-morph {
0%, 100% { --ax-radius: 50%; --ax-edge-depth: 0px; }
25% { --ax-radius: 28%; --ax-edge-depth: 0px; }
50% { --ax-radius: 14%; --ax-corner-top-left-shift-y: 50%; --ax-corner-top-right-shift-x: 50%; --ax-corner-bottom-right-shift-y: 50%; --ax-corner-bottom-left-shift-x: 50%; --ax-edge-depth: 0px; }
75% { --ax-radius: 19%; --ax-edge-depth: 22px; }
}import { borderWidthsFrom, shapeSpecFrom } from '@ax-design/surface';
// Surface runs this on every frame, from the values it just read: a shape in motion is a
// shape resolved again.
resolve(shapeSpecFrom(values, width, height), { borderWidths: borderWidthsFrom(values) });Reference
resolve(spec, options?) method
(spec: ShapeSpec, options?: ResolveOptions) => ResolvedShape
Turns a description into lines and arcs. It never throws over values that do not fit: it scales them down and reports them in clamps.
ShapeSpec property
{ width, height, corners, edges }
The description: the box in px, four CornerSpec (top left, top right, bottom right, bottom left) and four EdgeSpec (top, right, bottom, left).
CornerSpec property
{ radius, shape, shiftX, shiftY, shoulderRadius? }
One corner: the fillet radius in px, the curve, how far the point moved into the shape in px, and the radius that rounds where the curve meets the sides.
EdgeSpec property
{ kind, depth, span, at, ratio, tipRadius, shoulderRadius }
One side feature: what it is, its depth in px (positive out, negative in), its base width in px, the middle of that base as a fraction of the side (0 to 1), the far-end ratio for trapezoid, and the two corner rounds in px.
CornerShape property
'round' | 'bevel' | 'scoop' | 'notch'
The curve of a corner, with the same meaning as the CSS corner-shape keywords.
EdgeKind property
'flat' | 'arc' | 'rect' | 'triangle' | 'trapezoid' | 'cusp'
The feature of a side. A flat edge ignores the other EdgeSpec values.
ResolvedShape property
{ outline, vertices, bounds, clamps }
outline is the closed contour; vertices is the sharp contour with a radius per vertex, kept for debugging and for borders; bounds wraps every point; clamps lists what did not fit.
Contour, Segment property
{ segments: Segment[] }
A closed path. Consecutive segments share their endpoints; each is a line or an arc with its center, radius, start and end angle and direction.
Clamp property
{ kind: 'corner-shift' | 'corner-radius' | 'edge-span' | 'edge-depth' | 'vertex-radius', index, factor }
One value that did not fit, the corner or side it belongs to, and the factor below 1 that was applied to it. vertex-radius is the rounding at a single vertex, such as the tip of a cusp.
ResolveOptions property
{ borderWidths?, minNeck? }
borderWidths per side keeps inward features clear of the border; minNeck is the thickness kept between opposite inward features and defaults to two border widths.
toSvgPath(contour, precision?) method
(contour: Contour, precision?: number) => string, default precision = 3
The contour as SVG path data, ready for a d attribute.
traceContour(sink, contour) method
(sink: PathSink, contour: Contour) => void
Traces the contour on anything with moveTo, lineTo, arc and closePath: a canvas context or a Path2D.
PathSink property
{ moveTo, lineTo, arc, closePath }
The four methods traceContour uses, as in CanvasRenderingContext2D.
borderInner(outline, options) method
(outline: Contour, options: BorderOptions) => BorderResult
The inner edge of a border ring in local coordinates, with the thickness it has on screen per side. Offsets every segment, joins neighbours at the intersection of their offset lines, and removes segments that turned around.
BorderOptions property
{ widths, transform?, center?, tolerance? }
Widths per side in local px; the tilt as a Homography to keep the thickness even on screen; the center used for that scale (the outline center by default); and the maximum chord error of the arc sampling (0.1 px).
borderInner().inner, .thickness property
{ inner: Point[], thickness: Quad<number> }
inner is a closed polyline in local coordinates; thickness is what each side of the border measures on screen.
traceInner(sink, inner) method
(sink: RingSink, inner: Point[]) => void
Traces the inner edge as a second subpath. Fill it together with the outline using the even-odd rule.
innerToSvgPath(inner, precision?) method
(inner: Point[], precision?: number) => string, default precision = 3
The inner edge as SVG path data, to append to the outline path.
tiltHomography(params) method
(params: TiltParams) => Homography
The matrix of perspective() rotateX() rotateY() translateZ() around the center of a box, as it acts on the untilted plane. Pass it to borderInner as transform.
TiltParams property
{ width, height, perspective, rotateX, rotateY, translateZ }
The box and the angles in degrees, as in CSS. perspective is the distance in px; 0 or Infinity means no perspective.
apply(h, point), invert(h) method
(h: Homography, point: Point) => Point
Maps a point through a homography, and the inverse of one. apply divides by w, so it projects the tilted point back onto the plane of the screen.
scaleAt(h, point) method
(h: Homography, point: Point) => number
How much one local px grows on screen at that point. It is what makes a border keep its on-screen width.
IDENTITY property
Homography
The matrix that changes nothing, used when the element is flat.
Homography property
[number, number, number, number, number, number, number, number, number]
A 3x3 matrix in row-major order that maps local (x, y, 1) to screen coordinates.
Quad<T> property
[T, T, T, T]
Four values, in the order of the corners (top left, top right, bottom right, bottom left) or of the sides (top, right, bottom, left).
--ax-radius property
<length-percentage>, default 0px
Surface: the radius of every corner. Percentages are relative to the shorter side; Surface resolves them and passes px on.
--ax-{corner}-radius property
<length-percentage>, default -1px
Surface: the radius of one corner. A negative value means use --ax-radius.
--ax-corner-shape property
round | bevel | scoop | notch, default round
Surface: the shape of every corner.
--ax-corner-{corner}-shape property
round | bevel | scoop | notch | auto, default auto
Surface: the shape of one corner. auto means use --ax-corner-shape.
--ax-corner-shoulder-radius, --ax-corner-{corner}-shoulder-radius property
<length>, default 0px
Surface: rounds where a bevel, scoop or notch corner meets the sides. Round corners ignore it.
--ax-corner-{corner}-shift-x, --ax-corner-{corner}-shift-y property
<length-percentage>, default 0px
Surface: moves one corner point into the shape, x against the width and y against the height. Negative values move it out of the box.
--ax-edge-{side}-kind property
flat | arc | rect | triangle | trapezoid | cusp, default flat
Surface: the feature on one side, with the same meaning as EdgeKind. The side names are top, right, bottom, left.
--ax-edge-{side}-depth property
<length>, default 0px
Surface: how far the feature goes, positive out of the shape and negative into it.
--ax-edge-{side}-span property
<length-percentage>, default 33%
Surface: the width of the base, in px or as a share of the side.
--ax-edge-{side}-at property
<length-percentage>, default 50%
Surface: where the middle of the feature sits along the side. Sides run clockwise, so 20% on the bottom is near the bottom right corner.
--ax-edge-{side}-ratio property
<number>, default 0.5
Surface: for trapezoid, the width of the far end compared to the base. 1 gives a rectangle, 0 a triangle.
--ax-edge-{side}-tip-radius, --ax-edge-{side}-shoulder-radius property
<length>, default 0px
Surface: rounds the far corners of a feature and the corners where it meets the side.
--ax-edge-kind, --ax-edge-depth, --ax-edge-span, --ax-edge-at, --ax-edge-ratio, --ax-edge-tip-radius, --ax-edge-shoulder-radius property
as the side properties
Surface: the feature of all four sides at once. A side that sets its own value wins over the shared one.