IX Surfaces

Star on GitHubFeedback
IX SurfacesNative HTMLot-figure

Figure

A picture cropped to a ratio and marked like a printer's proof, its number, caption and credit set in type underneath or in the margin beside it.

Report an issue with Figure

Example

A calm sea under a pale sky, the horizon across the middle of the frame.
Figure 01
Sea at first light. The crop keeps the horizon on the middle line.Study, drawn in SVG
Light from a tall window falling across a dark wall and floor.
Figure 02
A window's light on the wall at four in the afternoon.Study, drawn in SVG
Plate, 3 : 2
A calm sea under a pale sky.
Figure 01
Sea at first light.Study
Picture only
Light from a window on a dark wall.
Missing picture
A calm sea under a pale sky, the horizon across the middle.
Figure 03
The description stands in its place.
Right to left
بحر هادئ تحت سماء شاحبة.
Figure 04
البحر عند أول الضوء.دراسة

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, aspect-ratio, and meta along.

npx shadcn@latest add https://thedirectors.agency/ui/r/figure.json

Usage

This example also uses Toggle group.

npx shadcn@latest add https://thedirectors.agency/ui/r/toggle-group.json
figure-example.tsx
"use client"import * as React from "react"import { Figure } from "@/components/ui/figure"import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"// Two studies drawn in SVG, so the specimen needs no image files: a sea at first light and a window's light on a wall.const svg = (s: string) => `data:image/svg+xml,${encodeURIComponent(s)}`const sea = svg(`<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1000" viewBox="0 0 1500 1000"><defs><linearGradient id="s" x2="0" y2="1"><stop offset="0" stop-color="#77787b"/><stop offset="1" stop-color="#d4d5d6"/></linearGradient><linearGradient id="w" x2="0" y2="1"><stop offset="0" stop-color="#4b4c50"/><stop offset="1" stop-color="#141518"/></linearGradient><radialGradient id="h" cx=".5" cy=".53" r=".5" gradientTransform="matrix(1 0 0 .22 0 .41)"><stop offset="0" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient><filter id="g"><feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="9"/><feColorMatrix values="0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .35 0"/><feBlend in2="SourceGraphic" mode="overlay"/></filter></defs><g filter="url(#g)"><rect width="1500" height="530" fill="url(#s)"/><rect y="530" width="1500" height="470" fill="url(#w)"/><rect width="1500" height="1000" fill="url(#h)"/></g></svg>`)const wall = svg(`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1500" viewBox="0 0 1200 1500"><defs><linearGradient id="b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2a2b2e"/><stop offset="1" stop-color="#0e0f11"/></linearGradient><filter id="s"><feGaussianBlur stdDeviation="14"/></filter><filter id="g"><feTurbulence type="fractalNoise" baseFrequency=".8" numOctaves="2" seed="3"/><feColorMatrix values="0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .4 0"/><feBlend in2="SourceGraphic" mode="overlay"/></filter></defs><g filter="url(#g)"><rect width="1200" height="1500" fill="url(#b)"/><g filter="url(#s)" fill="#e6e6e3"><path d="M430 250 L780 330 L780 1010 L430 1150 Z" opacity=".92"/><path d="M430 1150 L780 1010 L1160 1500 L520 1500 Z" opacity=".22"/></g></g></svg>`)const ratios = [["3:2", 3 / 2],["16:9", 16 / 9],["1:1", 1],["4:5", 4 / 5],] as constexport default function Example() {const [ratio, setRatio] = React.useState("3:2")const value = ratios.find(([name]) => name === ratio)?.[1] ?? 3 / 2return (<div className="grid w-full gap-(--ot-space-8)"><div className="grid gap-(--ot-space-7)">{/* Radix single groups clear on a second press; a crop is always one of the four. */}<ToggleGroup type="single" value={ratio} onValueChange={(v) => v && setRatio(v)} aria-label="Crop">{ratios.map(([name]) => (<ToggleGroupItem key={name} value={name}>{name}</ToggleGroupItem>))}</ToggleGroup><Figuresrc={sea}alt="A calm sea under a pale sky, the horizon across the middle of the frame."ratio={value}position="50% 53%"number="01"caption="Sea at first light. The crop keeps the horizon on the middle line."credit="Study, drawn in SVG"/></div><Figurevariant="margin"src={wall}alt="Light from a tall window falling across a dark wall and floor."ratio={4 / 5}number="02"caption="A window's light on the wall at four in the afternoon."credit="Study, drawn in SVG"/></div>)}

Contract

  • Underneath: native <figure> and <img>, plus a hook that notices a picture that couldn't load.
  • Anatomy: <figure class="ot-figure" data-variant="plate | margin"> holding a ot-ratio frame (.ot-figure-frame, the aspect ratio's crop) with the <img class="ot-figure-img"> and .ot-figure-alt (the alt text again, aria-hidden), then <figcaption class="ot-figure-caption"> with .ot-figure-number (a .ot-sr "Figure " before it) and a ot-meta row (.ot-figure-lines) of .ot-figure-text and .ot-figure-credit. data-missing marks a picture that failed.
  • The type-led answer to image masking: the job of framing a picture, without its silhouettes. The picture is cropped to a ratio (3 : 2 by default, the 35mm negative; position holds the subject) and marked like a printer's proof, the aspect ratio's trim marks outside its corners and no border. Then it is named in type, as "the silence that heals" sets its camera and its archive under its picture: the number as data (--ot-mp, light, tabular), the caption in the voice, and the credit in the pencil at the far end of the row, held apart by the meta row's flexing hairline. No filter, no mask, no hover zoom: the picture is shown as it is. Change ratio and the frame eases to it, the picture recropping inside.
  • A picture that can't load leaves its frame: the marks stay and the alt text is set inside them in the pencil, the way a layout holds a place for a picture to come. The <img> stays for readers.
  • variant (data-variant; none for the default, plate): margin.
  • margin: a side caption, as a book sets one in the columns kept for notes (3 of 12, a --ot-space-6 gap clear of the marks). The number stands at the head, larger and lighter (--ot-mf, weight 200), level with the picture's top; the caption and the credit stand at the foot, level with its foot, one under the other. Where the two can't stand side by side (the picture under 20rem, the caption under 9rem) the caption falls under the picture. Right to left the margin is on the right.
  • Nothing to focus: a figure is read, not used.

Props

Props of Figure
PropTypeDefaultWhat it does
srcstringThe picture. A real <img>, lazily loaded; srcSet and sizes pass through.
altstringWhat the picture shows. Read aloud, and if the picture can't be loaded, set in the frame in its place, inside the trim marks.
variant"plate" | "margin""plate"plate: the caption row under the picture, the number first, the credit spread to the far end on the same baseline. margin: a side caption, standing in the margin columns beside the picture (3 of 12), the number at the head level with its top and the caption and credit at the foot level with its foot; where the two can't stand side by side the caption falls under the picture.
rationumber3 / 2The crop, width over height; 3 : 2 is the 35mm negative. Change it and the frame eases to the new ratio, the picture recropping inside it.
positionstring"50% 50%"Where the crop holds the picture, as CSS object-position, so the subject stays in the frame at every ratio.
numberReactNodeThe figure's number, set as data before the caption (01). Read as "Figure 01".
captionReactNodeWhat the picture is, in the voice.
creditReactNodeWho made it, in the pencil, at the far end of the caption row.
loading"lazy" | "eager""lazy"Use eager for a picture that must load immediately, such as the first visible image. Otherwise the browser loads it near the viewport.
srcSet / sizesstringNative responsive image candidates and slot sizes, passed to the img.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Figure"the silence that heals" (camera and archive under the picture); a printer's crop marksA picture cropped to a ratio, named in type: number, caption, credit spread to the far end
Margin figureA book's side caption in the note columnsThe number at the head of the margin, the caption at its foot, level with the picture

How it moves

How it moves
ComponentArticulationWhat moves
FigureBreathThe frame eases to a new ratio and the picture recrops inside it