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
miterroundbevelJSX
<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
nonzeroevenoddJSX
<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
nonenon-scaling-strokeJSX
<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
normalstrokeJSX
<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
startmiddleendJSX
<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
alphabeticcentralhangingJSX
<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
@box-kite/react/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: a prop table that takes
d for display writes a path no browser can draw, and reports nothing. 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.| 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 |
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
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
| 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 |
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>