SVG

Twenty-three SVG properties as typed props, and twenty components to put them on — paint, stroke, text and the SVG 2 geometry that lets a shape move with no JavaScript at all.
Paint
JSX
<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>

Set them once, on the element above

Every paint and stroke property here except 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.
The one thing inheritance cannot beat is a 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.

The numbers are user units

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.
Dashes
12
"12 4"
"1 8"
JSX
<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>

A pattern is a number or a string

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.
Drawing a path on hover
Hover the line.
JSX
// 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>

The transition is already there

Nothing in that example declares a transition. Every Box transitions on --transitionTime, and an <svg> and the shapes inside it transition on --svgTransitionTime — so moving strokeDashoffset under hover is the whole animation.
Which also means it stops when it should: a reader with prefers-reduced-motion: reduce gets both variables set to 0s, and the line simply appears.
Caps and joins
miter
round
bevel
JSX
<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>
Fill rule
nonzero
evenodd
JSX
<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>
A stroke that ignores the scale
none
non-scaling-stroke
JSX
<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>

vectorEffect is the exception

Both shapes above are drawn at 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.
Alone among the paint and stroke properties, 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.
Outlined text
normal
stroke
JSX
<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>

A shape's own numbers are props too

SVG 2 made the geometry attributes real CSS properties, so 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.
Which one applies to which element is the SVG spec's answer, not this library's. 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.
They are not inherited, and here that is the right behaviour rather than the problem it was for vectorEffect — a radius handed down to every shape below would be nonsense. Set them on the shape.
Two names are missing from the list, and they are the reason the elements have components of their own. A <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 moves
Hover each shape.
JSX
// 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>
Corners and position
rx={0}
rx={4}
rx={20}
JSX
<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>

rx is not borderRadius

The two read alike and mean different numbers. 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.
Anchoring a label
start
middle
end
JSX
<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>

dominantBaseline is the vertical half

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.
This is the second property CSS does not inherit — 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.
Text size is the ordinary 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.
Baselines against a line
alphabetic
central
hanging
JSX
<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>
A gauge, with no JavaScript
Hover the gauge.
JSX
// 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>

What the gauge is made of

Four props and no state. 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.
One thing here is still an attribute, and <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.
The transition is the one already on every shape inside an <svg>, so a reader with prefers-reduced-motion: reduce gets a gauge that is simply there, with no sweep.

Every element is a component

@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.
Two are not named after their element. 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.

Where a name means two things

An SVG attribute and a Box prop can be the same word, and the clash is silent — Chakra once turned a path's 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.
One name can be answered twice, because the answer belongs to the element and not to the word. 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.
ComponentElementAttributes 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

A drawing says whether it means anything

An <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.
An illustration
Hover the picture — the sun is a cy and an r, and the gradient is two stops taking their colour from a Box prop.
JSX
<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>

What the illustration used to need props for

Two of these three were attributes until the chart primitives needed them to be values. The sky is 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.
A chart, by hand
JanFebMarAprMayJun
Hover the chart for the trend line, and a bar for its colour.
JSX
// 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>

What the chart is, and is not

A bar is a <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.
What it is not is a chart component — no scales, no ticks, no tooltip, no responsiveness beyond what the viewBox gives for free. Those are the next step. What this page shows is that the primitives underneath them already exist.

Every prop

PropCSS propertyValues
fillfillany colour variable, or none
fillOpacityfill-opacity0 – 1 in tenths
fillRulefill-rulenonzero, evenodd
strokestrokeany colour variable, or none
strokeOpacitystroke-opacity0 – 1 in tenths
strokeWidthstroke-widtha number, in user units
strokeLinecapstroke-linecapbutt, round, square
strokeLinejoinstroke-linejoinmiter, round, bevel
strokeMiterlimitstroke-miterlimita number, 1 or greater
strokeDasharraystroke-dasharraya number, or the pattern as a string
strokeDashoffsetstroke-dashoffseta number, or a percentage of the path length
paintOrderpaint-ordernormal, fill, stroke, markers
vectorEffectvector-effectnone, non-scaling-stroke
shapeRenderingshape-renderingauto, optimizeSpeed, crispEdges, geometricPrecision
textAnchortext-anchorstart, middle, end
dominantBaselinedominant-baselineauto, alphabetic, central, middle, hanging, text-top, text-bottom, ideographic, mathematical
cxcxa number in user units, or a percentage
cycya number in user units, or a percentage
rra number in user units, or a percentage
rxrxa number, a percentage, or auto
ryrya number, a percentage, or auto
xxa number in user units, or a percentage
yya number in user units, or a percentage

Themes, pseudo-classes, breakpoints

These are ordinary Box props, so everything that nests around a Box prop nests around them. A stroke that changes with the theme, a fill that reacts to hover and a width that grows at a breakpoint are all one prop each.
JSX
<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>