Skip to main content

Browser Translation

A reader who runs Google Translate on your page gets a rewritten DOM. That breaks two things.

The content goes wrong. A translated CLI flag, env var, shortcut key, or one-time passcode names something that does not exist, and the reader copies it anyway.

The render goes wrong. The engine replaces each text node with a <font> wrapper and reparents the original node: React's reference to it stays alive, but its parent is now the <font>. The next update that names that node (an icon inserting before it, or the node itself unmounting) asks the DOM to touch a child its parent no longer owns. The DOM raises NotFoundError, React re-throws the raw DOMException, and with no error boundary above it the React root tears down. The page goes blank.

This is not hypothetical. It shipped in mantle 0.83.2: the first click of any submit button blanked a translated page, because isLoading synthesized an icon beside bare text children.

What survives the rewrite and what does not

The failure needs React to insert before, or remove, a text node the engine reparented. Element children are never reparented.

UpdateResult
Insert an element before a translated text nodeThrows
Remove a text child while siblings remainThrows
A text child changes type to an elementThrows
Reorder element children before a text nodeThrows. React inserts before the next host sibling, which can be text.
Unmount a parent with no host node of its ownThrows on a bare text child. React removes each child by itself.
Change a text child's valueSafe. The translation reverts.
Remove an element childSafe. An element is never reparented.
Append after a translated text nodeSafe.
Unmount a subtree that owns a host nodeSafe. React removes that one node.
A lone string, number, or bigintSafe, and self-healing.

The last row is the one to design toward, and it is narrower than it looks. When a host element's children is one string, one number, or one bigint, React writes it through textContent, which wipes the <font> wrapper and repairs the subtree instead of fighting it. A component element that returns bare text at its root does not qualify. That component owns no host node, so React mounts and removes the text node by itself.

The reorder row is about the reference node, not the moved one. React picks that node by walking to the next host sibling. The walk returns a text node as readily as an element, so a keyed list that re-sorts in front of a bare text summary throws.

The unmount row is about the outermost host node. A portal, a Fragment, an array, and a component that returns bare text own none. React removes each of their host children one at a time, so a bare text child among them throws.

What mantle guarantees

Each bullet is one cure. A part picks the cure its own shape needs.

  • A label slot wraps the text. The default, and what most parts do. Button, Badge, and Anchor render children inside one, so an appearing icon or isLoading spinner inserts before an element sibling. Select.Item wraps children, and Select.Value wraps its placeholder and your own children the same way. So do Select.Trigger, Tooltip.Content, Tabs.Trigger on both paths, Toast.Root, MultiSelect.Item, DropdownMenu.SubTrigger, DropdownMenu.CheckboxItem, DropdownMenu.RadioItem, DataTable.ActionCell, DataTable.ActionHeader, SelectableList.SelectAll, SelectableList.Empty, AlertCenter.Item, and each chart's Tooltip and Legend render prop. Every slot but Anchor's is display: contents, so it adds no box and changes no layout.
  • A wrapper that never unmounts holds a conditional sibling. Sidebar.Trigger and Sidebar.Tooltip always render the flex row that pairs a row's label with its chord chips, so a shortcut that resolves after the first paint appends beside an element. Sidebar.SearchTrigger always renders its chord span on the default button path and hides it with display: none while it is empty.
  • A decorative sibling renders after the text. DataTable.ActionHeader renders its sticky indicator last, which turns the mount into an append. The move cures that mount and nothing else, so the part carries a label slot too.
  • An announcer stays mounted and takes a lone string. DataTable.HeaderSortButton renders its label through Button's label slot, and the sort state lives in attributes (aria-sort on the header cell, data-sort-direction on the button), so a sort change inserts no node before text.
  • Untranslatable content carries translate="no". Code, Kbd, TruncatedText, CodeBlock.Code, CodeBlock.Title, Command.Shortcut, DropdownMenu.Shortcut, and OtpInput.Slot mark their content, so the engine skips those subtrees entirely. The attribute classifies content and is never a part's fix for a crash. The attribute rides on a Select.Item's portaled label too, so an ID or a path stays untranslated in the trigger. It rides on the two chord slots too: sidebar-tooltip-shortcut in Sidebar.Trigger and Sidebar.Tooltip, and sidebar-search-trigger-shortcut in Sidebar.SearchTrigger. A chord passed as a raw <kbd> or a bare string keeps its key name there.

Each label slot is public API. Its name is the part's own data-slot plus -label, and the part's docs page lists it.

One caveat, and it is the sentence to act on: when a mantle part renders anything beside your children, your children are not a lone child, and the self-healing row does not apply to them. That is why these parts wrap, and it is the rule your own components need too.

Breadcrumb is the exception that cannot wrap: Breadcrumb.List renders an <ol>, so a wrapper would nest an <li> in an <li>. A content crumb must render a host element at its root instead.

A label slot protects the insert mantle makes. It cannot protect an insert your own children make inside it; that is the next section.

Who owns what

  • Mantle owns its parts. Every part stays safe on a translated page with plain children, a bare string included. A part that renders anything beside your children wraps them in a label slot, so the node React removes is an element. A crash inside a part's own markup is a mantle bug: report it, and do not work around it with translate="no".
  • You own your own markup. The rules below cover the shapes a component of yours can emit. A label slot cannot protect a conditional element you render before bare text inside it.
  • Both keep translate="no" to content. Set it on a string a reader copies or retypes, through the part that owns it when one exists. Never set it to stop a crash, and never set it on prose. A translated page that stays readable is the goal, and the attribute takes a string out of that page, so every use has to earn it.

The rules for your own code

Never render a conditional element immediately before bare text children.

// ❌ when `isNew` flips on, React inserts the icon before the translated text node<div className="flex items-center gap-2">	{isNew && <SparkleIcon />}	Endpoint created</div> // ✅ the insert names an element, which no engine moves<div className="flex items-center gap-2">	{isNew && <SparkleIcon />}	<span>Endpoint created</span></div>

Three fixes work. Pick by what the conditional element is:

  • Wrap the text in an element. The default answer, and what mantle's own parts do.
  • Move the element after the text. A mount then becomes an append, which is safe. Prefer this when the element is decorative and out of flow, because it adds no DOM.
  • Mount the element unconditionally and write text into it. A textContent write is safe and self-healing. Prefer this for a sr-only announcer, which costs no layout.

Two more shapes throw with no conditional element in sight.

Never re-sort a keyed list in front of bare text. React inserts each moved element before the next host sibling. That sibling can be the text node. Wrap the text, and the moved element then names an element.

Never leave bare text as a direct child of a portal, a Fragment, or an array. None of the three owns a host node, and neither does a component that returns bare text at its root. React removes each child by itself, so a bare text child is the outermost node.

// ❌ the Fragment owns no host node, so React removes the bare description by itself<dl>	{rows.map((row) => (		<Fragment key={row.id}>			<dt>{row.term}</dt>			{row.description}		</Fragment>	))}</dl> // ✅ the description is an element now, and React removes it in one call<dl>	{rows.map((row) => (		<Fragment key={row.id}>			<dt>{row.term}</dt>			<dd>{row.description}</dd>		</Fragment>	))}</dl>

A child list that never changes is safe, however long it is. Every shape above needs an update that moves, removes, or mounts a node beside text that stays put.

Buttons with an icon, or two

Pass a button's icon through icon, never as a child. The slot sizes the glyph, holds it at shrink-0, tightens the padding on its side, and hands its place to the spinner when isLoading turns on.

A button can still carry a second icon. Put it after the label text as a child: the icon slot and every child of the label slot are flex items of the button, so the button's gap falls between all of them.

<Button appearance="outlined" intent="neutral" icon={<PlusIcon />}>	<span>Create endpoint</span>	<CaretDownIcon /></Button>

The label text is wrapped, which matters when that second icon is conditional: a trailing icon appends and is safe either way, but a leading child icon inserts before the text, and the wrapper is what keeps it from throwing.

Marking content untranslatable

Set translate="no" on the element that holds a string a reader copies or retypes: code, a CLI flag, an env var, a YAML key, a shortcut key, a filename, an ID, a passcode. Reach for the part that already carries it before a raw attribute: Code, Kbd, CodeBlock.Code, CodeBlock.Title, Command.Shortcut, DropdownMenu.Shortcut, and OtpInput.Slot.

<span translate="no">{endpointId}</span>

The attribute classifies content. It is not a fix for a crash. Descendants inherit it, so the engine skips the subtree and a crash shape under it stops throwing, but the shape stays, and the next prose child hits it. When one of the rules above throws, fix the structure, and set translate="no" only when the content also belongs on the list. A search query, a name, a status label, and any other prose stay translatable.

Code and CodeBlock.Title set the attribute as an overridable default, because both also hold prose. Pass translate="yes" there when the content is a word rather than a token. Kbd, CodeBlock.Code, Command.Shortcut, DropdownMenu.Shortcut, and OtpInput.Slot lock it: those omit the prop from their types, so no call site can turn the guard off.

A label that has to shrink

The label slot takes no styling of its own: display: contents generates no box, so there is nothing there to size. Style your own child instead, which is still a flex item of the button:

<Button	appearance="outlined"	intent="neutral"	className="max-w-48 min-w-0 justify-start"	icon={<PlusIcon />}>	<span className="min-w-0 flex-1 truncate text-left">		A label far too long for the width of this button	</span>	<CaretDownIcon /></Button>

This is the shape a wrapper box breaks. flex-1 and min-w-0 need the span to be a flex item of the button, so a label slot that laid out a box would drop both and the text would push past the button's width instead of clamping.

Testing it

Reproduce what the engine does, then run the update that used to throw. Wrapping the text nodes is the whole model:

/** Wrap every non-blank text node under `root` in a `<font>`, the way Google Translate does. */function translateTextNodes(root: Node) {	const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {		acceptNode: (node) =>			node.parentElement?.closest("[translate=no]") == null				? NodeFilter.FILTER_ACCEPT				: NodeFilter.FILTER_REJECT,	});	const nodes: Text[] = [];	for (let node = walker.nextNode(); node != null; node = walker.nextNode()) {		if (node.nodeValue?.trim()) {			nodes.push(node as Text);		}	}	for (const node of nodes) {		const font = document.createElement("font");		node.parentNode?.insertBefore(font, node);		font.appendChild(node);	}}

Rejecting a translate="no" subtree is what makes that attribute testable: a component that stamps it survives a mutation that would otherwise detach the text nodes React holds. happy-dom raises the same NotFoundError as Chromium here, so these tests need no real browser.

Limits

The model is a reparent, which is what Google Translate does. An engine that instead detaches and replaces the text node produces a different symptom. Safari and Firefox translation are unverified.

Nothing here patches the DOM. A Node.prototype.removeChild fallback would make arbitrary product code safe, and mantle does not install one: the guarantees above are structural, per component.