"use client"import*asReactfrom"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.constsvg=(s:string)=>`data:image/svg+xml,${encodeURIComponent(s)}`constsea=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>`)constwall=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>`)constratios=[["3:2",3/2],["16:9",16/9],["1:1",1],["4:5",4/5],]asconstexportdefaultfunctionExample(){const[ratio,setRatio]=React.useState("3:2")constvalue=ratios.find(([name])=>name===ratio)?.[1]??3/2return(<divclassName="grid w-full gap-(--ot-space-8)"><divclassName="grid gap-(--ot-space-7)">{/* Radix single groups clear on a second press; a crop is always one of the four. */}<ToggleGrouptype="single"value={ratio}onValueChange={(v)=>v&&setRatio(v)}aria-label="Crop">{ratios.map(([name])=>(<ToggleGroupItemkey={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
Prop
Type
Default
What it does
src
string
The picture. A real <img>, lazily loaded; srcSet and sizes pass through.
alt
string
What 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.
ratio
number
3 / 2
The 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.
position
string
"50% 50%"
Where the crop holds the picture, as CSS object-position, so the subject stays in the frame at every ratio.
number
ReactNode
The figure's number, set as data before the caption (01). Read as "Figure 01".
caption
ReactNode
What the picture is, in the voice.
credit
ReactNode
Who 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 / sizes
string
Native responsive image candidates and slot sizes, passed to the img.
Craft
Where its move comes from
Where its move comes from
Component
Reference
Move
Figure
"the silence that heals" (camera and archive under the picture); a printer's crop marks
A picture cropped to a ratio, named in type: number, caption, credit spread to the far end
Margin figure
A book's side caption in the note columns
The number at the head of the margin, the caption at its foot, level with the picture
How it moves
How it moves
Component
Articulation
What moves
Figure
Breath
The frame eases to a new ratio and the picture recrops inside it