Skip to content

SVG

SVG inside JSX works without ceremony for the common case (a JSX subtree with <svg> as the root tag). The HTML5 parser recognizes <svg> and switches to “foreign content” mode for its descendants, applying the SVG namespace correctly.

mount(rootEl, () => (
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="blue" />
<path d="M 0 50 L 100 50" stroke="red" />
</svg>
));

The above renders correctly. The diff updates SVG attributes (d, r, fill, transform, …) just like any other attributes.

The naive parser path breaks for SVG fragments WITHOUT an <svg> wrapper:

const path = '<path d="M 0 0 L 10 10" />';
const t = document.createElement('template');
t.innerHTML = path;
const el = t.content.firstElementChild;
// el is HTMLUnknownElement, not SVGPathElement.
// el.namespaceURI is "http://www.w3.org/1999/xhtml", not the SVG namespace.
// Appending el to a parent <svg> doesn't paint.

You hit this when:

  • Generating an SVG fragment server-side and inserting it into an existing <svg> parent.
  • Building reusable SVG icon helpers that return just a <g> group.
  • Composing SVG fragments dynamically.

toElement(jsx) from kerf detects SVG content and routes through DOMParser with the image/svg+xml MIME, which guarantees correct namespacing for all descendants:

import { toElement } from 'kerfjs';
const path = toElement('<path d="M 0 0 L 10 10" />');
// ↑ now an SVGPathElement, namespaced correctly.
const svgRoot = document.querySelector('svg')!;
svgRoot.appendChild(path); // paints correctly
  1. Parses the input through a <template>.innerHTML (HTML5 parser, which already handles <svg> as foreign content with correct namespacing).
  2. Single-root input (one element with optional surrounding whitespace):
    • If the element is <svg> → re-parse the input via new DOMParser().parseFromString(html, 'image/svg+xml') and return the document element. The XML re-parse is strict, so malformed SVG (<svg><unclosed</svg>) throws instead of being silently auto-corrected.
    • If the element is an orphan SVG-namespace tag (any of g, path, circle, rect, line, polygon, polyline, ellipse, text, tspan, defs, use, symbol, clipPath, mask, pattern, filter, marker, linearGradient, radialGradient, stop, image, foreignObject) → wrap in <svg xmlns="...">, XML-parse, return the first child. The caller is responsible for parenting it inside an existing <svg> to render.
    • Otherwise (plain HTML) → return the parsed element directly.
  3. Multi-root input (multiple elements, or any non-whitespace text alongside an element — <svg/> label, two icons side by side, text<svg/>) → return the parsed DocumentFragment as-is. The HTML5 parser already gave any <svg> children the right namespace; the DOM insertion APIs splat the fragment’s children into the parent on insert. Nothing is dropped.

7.4 When you need toElement vs. when mount is enough

Section titled “7.4 When you need toElement vs. when mount is enough”
  • mount() is enough when your SVG has an <svg> root tag in the JSX. The HTML5 parser handles namespacing inside foreign content — including each() rows, which are re-parsed in their parent’s namespace on every update, not just the first render.
  • MathML works the same way. A <math> root tag puts the parser in foreign-content mode, and each() rows under a MathML-namespaced parent are re-parsed as MathML on every update (granular insert, snapshot rebuild), not just first paint — the same guarantee kerf gives SVG rows.
  • HTML rows under a foreign integration point stay HTML. Where the HTML parser re-enters HTML content — SVG <foreignObject>, <desc>, <title> and the MathML text elements <mi>, <mo>, <mn>, <ms>, <mtext> — an each() list of HTML rows is re-parsed as HTML on every update, matching first paint, so the rows keep the XHTML namespace and aren’t mistakenly wrapped.
  • toElement() is the escape hatch for direct DOM construction OR for SVG fragments inserted ad-hoc into an existing <svg>.

If you’re not sure which you need, default to mount(). The vast majority of SVG icon and chart use cases work fine without toElement.

Security: toElement/morph on SVG strings run trusted markup only

Section titled “Security: toElement/morph on SVG strings run trusted markup only”

toElement() (and morph() with a string template) parse their input into live DOM with no escaping and no sanitization — the same trust model as innerHTML / raw(). For SVG this bites harder than for HTML: SVG is active content. A top-level <svg><script>…</script></svg>, an SVG event attribute (onload, <animate onbegin="…">), an xlink:href="javascript:…" on <a>/<use>, and HTML inside <foreignObject> all execute once the parsed node is inserted into the live document — whereas an HTML-string <script> you pass to toElement() is inert (the HTML path parses through <template>.innerHTML, which never runs scripts). The strict XML re-parse rejects malformed SVG, but well-formed malicious SVG passes through untouched.

So: only pass toElement()/morph() SVG (or HTML) markup you trust — authored in your own JSX, or sanitized upstream with an SVG-aware sanitizer (e.g. DOMPurify with SVG profiles). Never hand it unsanitized user input. If you need to render user-supplied SVG, sanitize first, then raw() it.

7.5 Other namespacing quirks (HTML5 parser oddities)

Section titled “7.5 Other namespacing quirks (HTML5 parser oddities)”

A handful of theoretical edge cases that the AST-based approach in some other reactive libs handles but kerf doesn’t, in exchange for a simpler runtime:

  • Custom-element is="..." attributes inside <table> / <select> parents have parser-quirk handling that depends on the surrounding context. Rarely a problem in practice.
  • Whitespace-only text nodes between sibling elements are sometimes normalized by the parser. Same — rarely matters.
  • xlink:href requires the alias config the JSX runtime already provides (xlinkHref).

If you hit one of these, file an issue — they’re fixable as a more complete toElement if there’s demand.