<Svg viewBox="0 0 120 48" width="120px" fill="violet-500" stroke="violet-300" strokeWidth={2}>
<Circle cx={24} cy={24} r={16} />
<Rect x={52} y={8} width={32} height={32} fillOpacity={0.4} />
<Circle cx={104} cy={24} r={16} fill="none" strokeWidth={4} strokeOpacity={0.5} />
</Svg>vectorEffect is an inherited one, so a value on the <svg> reaches every shape inside it — that is why the demo above sets stroke once rather than on all three shapes. A shape that wants something else states it, and wins for itself.fill or stroke attribute written on the shape itself: a presentation attribute on an element outranks a value inherited from its parent. Icon sets that hard-code fill="currentColor" on each path are the common case — style those paths, not the wrapper.strokeWidth, strokeDasharray, strokeDashoffset and strokeMiterlimit take the number and pass it straight through — strokeWidth={2} is stroke-width: 2. No divider is applied, because an SVG length is measured in the coordinate system the viewBox sets up, not in rem or in pixels. That is the same number you would have written in the attribute.fillOpacity and strokeOpacity use the same 0–1 scale as opacity, in tenths.12"12 4""1 8"<Svg viewBox="0 0 200 12" width="200px" stroke="emerald-500" strokeWidth={4} fill="none" strokeLinecap="round">
<Line x1={4} y1={6} x2={196} y2={6} strokeDasharray={12} />
</Svg>strokeDasharray={12} is a 12-long dash and a 12-long gap. Anything else is the CSS value as a string — "12 4" for a long dash and a short gap, "1 8" with a round cap for a dotted line. The space survives the class name: it becomes an underscore there, so strokeDasharray="12 4" is the class strokeDasharray-12_4 and one rule.// The track is drawn once. The line over it starts pushed off its own dash and slides back on hover.
<Box position="relative" width={50} height={12}>
<Svg viewBox="0 0 200 48" width="200px" fill="none" stroke="slate-200" strokeWidth={3} strokeLinecap="round">
<Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
</Svg>
<Svg
viewBox="0 0 200 48"
width="200px"
position="absolute"
inset={0}
fill="none"
stroke="violet-500"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={320}
strokeDashoffset={320}
hover={{ strokeDashoffset: 0 }}
>
<Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
</Svg>
</Box>--transitionTime, and an <svg> and the shapes inside it transition on --svgTransitionTime — so moving strokeDashoffset under hover is the whole animation.prefers-reduced-motion: reduce gets both variables set to 0s, and the line simply appears.miterroundbevel<Svg viewBox="0 0 72 40" width="72px" fill="none" stroke="amber-500" strokeWidth={12} strokeLinejoin="round" strokeLinecap="round">
<Path d="M10 32 L36 10 L62 32" />
</Svg>nonzeroevenodd<Svg viewBox="0 0 64 64" width="64px" fill="sky-500" fillRule="evenodd">
<Path d="M32 4 L39 24 L60 24 L43 37 L50 58 L32 45 L14 58 L21 37 L4 24 L25 24 Z" />
</Svg>nonenon-scaling-stroke<Svg viewBox="0 0 12 12" width="96px" fill="none" stroke="rose-500" strokeWidth={1} vectorEffect="non-scaling-stroke">
<Path d="M1 11 L6 1 L11 11 Z" />
</Svg>strokeWidth={1} in a 12-unit viewBox blown up to 96 pixels, so the left one comes out eight units thick. non-scaling-stroke measures the stroke after the transform instead, which is what a chart redrawn at any width needs.vector-effect is not inherited — a value on the <svg> would reach nothing. So this prop, and only this prop, writes a rule that names the element and its descendants: .vectorEffect-non-scaling-stroke, .vectorEffect-non-scaling-stroke *. Put it wherever it reads best.normalstroke<Svg viewBox="0 0 200 48" width="200px" fill="white" stroke="indigo-600" strokeWidth={6} paintOrder="stroke" strokeLinejoin="round">
<SvgText x={8} y={36} fontSize={36} fontWeight={700}>
Box
</SvgText>
</Svg>cx, cy, r, rx, ry, x and y are Box props here. They are user units like every other SVG length on this page — r={20} is r: 20 — and a percentage is of the viewport the viewBox describes.cx and cy centre a <circle> or an <ellipse>; r is the circle's radius; rx and ry are the ellipse's two radii and a <rect>'s corners; x and y position a <rect>, <image>, <use>, <foreignObject> or a nested <svg>. On an element with no such geometry the property is simply ignored, exactly as the attribute would be.vectorEffect — a radius handed down to every shape below would be nonsense. Set them on the shape.<rect>'s width and height are CSS properties too, but those prop names were taken years ago by the layout scale, where width={32} means 8rem. So <Rect> claims them back for itself: <Rect width={40} height={40} /> is forty user units square, written as an attribute the way SVG writes it.// Geometry is CSS, so it transitions. There is no JavaScript in this.
<Svg viewBox="0 0 160 56" width="160px" fill="violet-500">
<Circle cx={28} cy={28} r={12} hover={{ r: 22 }} />
<Circle cx={80} cy={28} r={12} hover={{ cy: 14, r: 8 }} />
<Ellipse cx={132} cy={28} rx={20} ry={10} hover={{ rx: 10, ry: 20 }} />
</Svg>rx={0}rx={4}rx={20}<Svg viewBox="0 0 56 56" width="56px" fill="teal-500">
<Rect width={40} height={40} x={8} y={8} rx={4} hover={{ rx: 20 }} />
</Svg>borderRadius={8} is on the spacing scale and comes out as 2rem; rx={8} is eight user units inside the viewBox. A rect wants rx.startmiddleend<Svg viewBox="0 0 200 40" width="200px" fill="slate-700" textAnchor="middle">
<SvgText x={100} y={22} fontSize={14}>
the label
</SvgText>
<Line x1={100} y1={26} x2={100} y2={38} stroke="rose-400" strokeWidth={2} />
</Svg>textAnchor decides which part of the text sits on its x; dominantBaseline decides which part sits on its y. central centres a number inside a gauge, hanging drops a label below an axis line, and alphabetic is where every browser starts.vectorEffect was the first — so it gets the same treatment: a rule that names the element and its descendants. Set it on the <svg> and every label inside obeys.fontSize prop, divider 16 like everywhere else. Inside an <svg> a pixel is a user unit, so fontSize={20} is 20 units and scales with the viewBox along with the shapes.alphabeticcentralhanging<Svg viewBox="0 0 120 40" width="120px" fill="slate-700" dominantBaseline="central">
<Line x1={0} y1={20} x2={120} y2={20} stroke="rose-400" strokeWidth={1} />
<SvgText x={8} y={20} fontSize={14}>
the label
</SvgText>
</Svg>// The ring is one dash as long as the circle's own circumference, pushed off the path and then
// pulled back. The number is placed at the centre point and centred on it by the two text props.
<Svg viewBox="0 0 96 96" width="96px" className="gauge" fill="none" strokeLinecap="round">
<Circle cx={48} cy={48} r={38} stroke="slate-200" strokeWidth={10} />
<Circle
transform="rotate(-90 48 48)"
cx={48}
cy={48}
r={38}
stroke="indigo-600"
strokeWidth={10}
strokeDasharray={239}
strokeDashoffset={239}
hoverGroup={{ gauge: { strokeDashoffset: 60, r: 40 } }}
/>
<SvgText x={48} y={48} textAnchor="middle" dominantBaseline="central" fontSize={20} fill="slate-700">
75%
</SvgText>
</Svg>strokeDasharray={239} makes the ring one dash as long as its own circumference — 2π × 38 is about 239 — and strokeDashoffset={239} pushes that dash entirely off the path, so nothing shows. Moving the offset to 60 leaves 179 of the 239 drawn, which is the three quarters the label claims.r grows by two units at the same time, which is the geometry tier doing something the attribute cannot. The number in the middle sits at the centre point and is centred on it by textAnchor and dominantBaseline — that pair is the whole reason SVG text is awkward to place by hand.<Circle> takes it as a prop of its own: transform="rotate(-90 48 48)" starts the arc at twelve o'clock. It is the SVG attribute rather than the rotate prop because it carries its own centre of rotation — CSS would turn the circle around the corner of the viewBox instead.<svg>, so a reader with prefers-reduced-motion: reduce gets a gauge that is simply there, with no sweep.@cronocode/react-box/components/svg is twenty components, one per element: Svg, G, Defs, Path, Circle, Ellipse, Rect, Line, Polyline, Polygon, SvgText, TSpan, LinearGradient, RadialGradient, Stop, ClipPath, Mask, Use, SvgSymbol and Marker. Each one is a Box, so every prop above works on it — and every demo on this page is built from them, without a single tag.SvgText is <text>, because one library cannot have a Text that means an SVG element and a Text that means a paragraph. SvgSymbol is <symbol>, because Symbol is a global that no module should quietly shadow.d into display this way. Here d is already the shorthand for flexDirection, a <rect>'s width is the ÷4 layout scale, and a <text>'s x is a CSS geometry property that does not apply to text at all. So each component settles those names for its own element: on Path, d is path data; on Rect, width is user units; on SvgText, x is the attribute. Everywhere else they keep their Box meaning.cx on a Circle is the CSS property, and transitions. cx on a RadialGradient is an attribute, because CSS geometry does not reach a gradient. Both are typed, and neither needs props.d is still not a styling prop even though CSS defines one: Safari does not support it, and a path that silently refuses to draw is worse than an attribute that always does. A paint server, on the other hand, no longer needs props at all: fill and stroke take url(#sky) and var(--chart-1) beside the palette, and so does clipPath — see the illustration below.| Component | Element | Attributes it takes as props |
|---|---|---|
Svg | <svg> | viewBox, preserveAspectRatio, width, height, label |
G | <g> | transform |
Defs | <defs> | — |
Path | <path> | d, transform, pathLength |
Circle | <circle> | transform, pathLength |
Ellipse | <ellipse> | transform, pathLength |
Rect | <rect> | width, height, transform, pathLength |
Line | <line> | x1, y1, x2, y2, transform, pathLength |
Polyline | <polyline> | points, transform, pathLength |
Polygon | <polygon> | points, transform, pathLength |
SvgText | <text> | x, y, dx, dy, textLength, lengthAdjust, transform |
TSpan | <tspan> | x, y, dx, dy, textLength, lengthAdjust |
LinearGradient | <linearGradient> | x1, y1, x2, y2, gradientUnits, gradientTransform, spreadMethod |
RadialGradient | <radialGradient> | cx, cy, r, fx, fy, gradientUnits, gradientTransform, spreadMethod |
Stop | <stop> | offset, stopColor, stopOpacity |
ClipPath | <clipPath> | clipPathUnits, transform |
Mask | <mask> | maskUnits, maskContentUnits, x, y, width, height |
Use | <use> | href, width, height, transform |
SvgSymbol | <symbol> | viewBox, preserveAspectRatio, x, y, width, height |
Marker | <marker> | markerWidth, markerHeight, refX, refY, orient, markerUnits, viewBox |
<Svg> with no label is aria-hidden. Most SVG on a page is decoration sitting beside the words that already say it, and a screen reader should walk past it — which is what nothing at all fails to say. Give it a label and it becomes role="img" with that name instead. State a role or an aria-labelledby of your own in props and the component steps out of the way entirely.cy and an r, and the gradient is two stops taking their colour from a Box prop.<Svg viewBox="0 0 200 120" width="100%" className="scene" label="A sun rising between two hills">
<Defs>
<LinearGradient id="sky" x1={0} y1={0} x2={0} y2={1}>
<Stop offset="0%" stopColor="currentColor" color="indigo-500" />
<Stop offset="100%" stopColor="currentColor" color="amber-200" />
</LinearGradient>
<ClipPath id="frame">
<Rect width={200} height={120} rx={10} />
</ClipPath>
</Defs>
<G clipPath="url(#frame)">
<Rect width={200} height={120} fill="url(#sky)" />
<Circle cx={64} cy={78} r={18} fill="amber-300" hoverGroup={{ scene: { cy: 44, r: 22 } }} />
<Path d="M-10 120 L64 68 L138 120 Z" fill="emerald-800" />
<Path d="M92 120 L156 56 L220 120 Z" fill="emerald-700" />
</G>
</Svg>fill="url(#sky)" and the rounded frame is clipPath="url(#frame)": a reference to something the document defines is a paint value like any other now, so it can differ per theme, on hover and per breakpoint, which an attribute never could. The third is not a gap at all — a gradient stop paints itself with stopColor="currentColor" so that the Box color prop, themed like everything else, is what actually decides it.// Six bars, an axis, a label under each, and a trend line that draws itself on hover.
<Svg viewBox="0 0 240 124" width="100%" className="chart" label="Revenue by month">
<Line x1={8} y1={96} x2={232} y2={96} stroke="slate-300" strokeWidth={1} />
{revenue.map(({ month, value }, index) => (
<Rect key={month} x={16 + index * 36} y={96 - value} width={24} height={value} rx={3} fill="sky-600" hover={{ fill: 'sky-400' }} />
))}
<Polyline
points={revenue.map(({ value }, index) => `${28 + index * 36},${96 - value}`).join(' ')}
fill="none"
stroke="amber-400"
strokeWidth={2}
strokeLinecap="round"
strokeDasharray={260}
strokeDashoffset={260}
hoverGroup={{ chart: { strokeDashoffset: 0 } }}
/>
{revenue.map(({ month }, index) => (
<SvgText key={month} x={28 + index * 36} y={112} textAnchor="middle" fontSize={11} fill="slate-500">
{month}
</SvgText>
))}
</Svg><Rect> whose y is a CSS property and whose height is an attribute — the split this page has been describing, in one element. The axis is a <Line>, the months are <SvgText> centred with textAnchor, and the trend is a <Polyline> drawing itself with the dash trick from further up the page. Nothing here is a chart library, and there is no state.viewBox gives for free. Those are the next step. What this page shows is that the primitives underneath them already exist.| Prop | CSS property | Values |
|---|---|---|
fill | fill | any colour variable, or none |
fillOpacity | fill-opacity | 0 – 1 in tenths |
fillRule | fill-rule | nonzero, evenodd |
stroke | stroke | any colour variable, or none |
strokeOpacity | stroke-opacity | 0 – 1 in tenths |
strokeWidth | stroke-width | a number, in user units |
strokeLinecap | stroke-linecap | butt, round, square |
strokeLinejoin | stroke-linejoin | miter, round, bevel |
strokeMiterlimit | stroke-miterlimit | a number, 1 or greater |
strokeDasharray | stroke-dasharray | a number, or the pattern as a string |
strokeDashoffset | stroke-dashoffset | a number, or a percentage of the path length |
paintOrder | paint-order | normal, fill, stroke, markers |
vectorEffect | vector-effect | none, non-scaling-stroke |
shapeRendering | shape-rendering | auto, optimizeSpeed, crispEdges, geometricPrecision |
textAnchor | text-anchor | start, middle, end |
dominantBaseline | dominant-baseline | auto, alphabetic, central, middle, hanging, text-top, text-bottom, ideographic, mathematical |
cx | cx | a number in user units, or a percentage |
cy | cy | a number in user units, or a percentage |
r | r | a number in user units, or a percentage |
rx | rx | a number, a percentage, or auto |
ry | ry | a number, a percentage, or auto |
x | x | a number in user units, or a percentage |
y | y | a number in user units, or a percentage |
<Svg
viewBox="0 0 24 24"
fill="none"
strokeWidth={2}
theme={{ dark: { stroke: 'slate-300' }, light: { stroke: 'slate-700' } }}
hover={{ strokeWidth: 3 }}
md={{ strokeWidth: 1.5 }}
>
<Path d="M4 12h16" />
</Svg>