Gå til hovedinnhold

Writing translatable components

Almost everything you already write is translatable. This page walks through the rules the plugin applies, the warnings it fires when it makes a judgement call you should know about, and the half-dozen patterns that break extraction silently.

The short version

  • JSX text inside a translatable element → extracted.
  • Direct text inside a container (<div>, <section>, …) → extracted (auto-promotion, silent).
  • Direct text inside an unmapped React component → extracted, with a warning and a suggestion to add a componentMap entry.
  • Inline elements with children (<strong>foo</strong>, <a href="…">here</a>, <em>{name}</em>) → captured as one translatable block; the inline element becomes a paired marker wrapping its inner content, so the translator sees the inner words and can move the wrapping around.
  • Zero-children inline elements (<br/>, <Icon/>, <Spinner/>, <Badge/>) → become standalone markers ({=mN} with no matching close) in the surrounding text.
  • HTML and ARIA text attributes (alt, title, placeholder, aria-label, …) on any element → extracted, including on an inline child whose text the sentence around it already carries.
  • React prop-name conventions (label, description, heading, helpText, tooltip, …) on PascalCase components only → extracted. On a plain <div> these names are usually DOM props or enum keys, not copy.
  • Translatable attributes with string-literal ternaries (title={cond ? "A" : "B"}) → each branch extracted as its own block.
  • Code spans inside a sentence (<code>, <kbd>, <samp>, <var>) → the sentence extracts as one block, the span becomes a paired marker, and its text is carried through verbatim.
  • A control inside a sentence (<button>, <label>, <select>, <img>, …) → the sentence extracts as one block and the control becomes a paired marker, with its label translatable inside. A control that is its parent's only content keeps its own block.
  • JSX inside a conditional ({cond && <span>…</span>}, a ternary, a .map()) → a standalone marker in the sentence, and the elements inside it are extracted and translated on their own.
  • A string literal in a conditional's branch ({saving ? "Saving..." : "Save"}, {cond && "Folder moved"}) → one block per branch, addressed by its slot.
  • A plain expression beside the text ({icon}, {rows}, {count}) → a named placeholder the translator can move. Whatever it evaluates to is rendered where its token sits, so an element stays an element and a number stays a number.
  • A whitespace-only expression ({" "}, {' '}, {` `}) → a space in the block's text, not a variable.
  • Non-translatable elements on their own (<code>, <pre>, <kbd>, <var>, <script>, <style>, <textarea>) → skipped.
  • Elements marked translate="no" (or any ancestor) → skipped.

The detail

Translatable JSX text

Headings, paragraphs, buttons, labels, options, <span>, <strong>, <em>, <a>, <b>, <i>: the whole set of elements the W3C HTML5 spec classifies as phrasing or translatable block content.

<h1>Welcome</h1> // ✓ extracted
<p>Ship in every language.</p> // ✓ extracted
<button>Get started</button> // ✓ extracted
<label>Email address</label> // ✓ extracted
<a href="/docs">Read the docs</a> // ✓ extracted
<option value="fr">French</option> // ✓ extracted

Inline children: one block, paired markers

When an element mixes text with inline children, the whole thing becomes one translatable block. Each inline element with children becomes a paired marker in the parent's text: the translator sees the inner words and can move the wrapping around:

<p>
Click <a href="/docs">here</a> to read the docs.
</p>

The extractor stores this as "Click {=m0}here{/=m0} to read the docs.". A German translation reads "Klicken Sie {=m0}hier{/=m0}, um die Dokumentation zu lesen.": the link wraps the right word, and a French translator can move it elsewhere in the sentence entirely.

Inline elements that produce paired markers: <span>, <strong>, <em>, <b>, <i>, <a>, <small>, <sub>, <sup>, <time>, <u>, <wbr>, <del>, <ins>, plus <code>, <kbd>, <samp> and <var>, whose text is carried through verbatim (see Code spans inside prose).

The form controls join that list where the sentence around them has words of its own (see Controls inside prose).

The rule is uniform: any inline element with at least one child → paired pair, regardless of whether the inner content is text, an expression, an icon, or further nested elements. Empty inline elements become standalone markers instead. A few examples:

SourceExtracted form
<a>here</a>"{=m0}here{/=m0}"
<a><Icon/></a>"{=m0}{=m1}{/=m0}"
<a>{userName}</a>"{=m0}{userName}{/=m0}"
<strong>{count}</strong>"{=m0}{count}{/=m0}"
<a>read <em>the</em> docs</a>"{=m0}read {=m1}the{/=m1} docs{/=m0}"
<Icon/> (no children)"{=m0}" (no matching {/=m0} close)
<br/> (no children)"{=m0}" (no matching {/=m0} close)

JSX-element tokens always read {=m<N>}; the runtime tells standalone from paired by looking for a matching {/=m<N>} close in the same scope. Variable tokens ({userName}, {count}) carry the JS identifier directly.

The explicit space

JSX drops the whitespace around a line break, so a sentence that wraps needs {" "} where the space would otherwise be lost:

<p>
Read the <a href="/docs">docs</a>{" "}
for the rest.
</p>

That space is a character the reader reads, so the block's text carries it: "Read the {=m0}docs{/=m0} for the rest.". {' '} and a template literal with nothing in it read the same way, and a run of whitespace collapses to one space just as JSX text does.

A literal with real text in it ({"!"}) is still a variable, because its value is a value.

Empty inline elements as standalone markers

Lots of real React UI looks like <Button><Icon />Open File...</Button>, an icon component followed by text. Empty inline elements (zero children) become a single standalone marker, leaving the surrounding text to extract normally:

<Button>
<FolderOpen size={12} />
Open File...
</Button>

Extracts as "{=m0} Open File..." with {=m0} bound to the <FolderOpen /> element (standalone, with no matching {/=m0} close). Works the same for Radix icons, lucide-react, Heroicons, custom <Spinner /> components: anything with no children.

Unmapped React components with children are still treated as block-level by default (the warning suggests a componentMap entry; see "Unknown components" below). The narrow rule for zero-children unmapped components prevents false positives on custom block-level components like <Panel><Heading>…</Heading></Panel>.

Auto-promoted containers

Strict W3C semantics would skip <div>Hello</div>: divs are classified as containers rather than text. In real React codebases that's wrong: <div>Label</div>, <section>Intro copy</section> are everywhere.

neokapi-i18n auto-promotes container elements when they have:

  1. At least one direct non-whitespace JSXText child, AND
  2. Only inline children (no nested block-level elements).

Promotion is silent: <div>Label</div> is the dominant idiom and warning on every occurrence would just be noise. (Unmapped React components still warn; see below.)

To opt out: <div translate="no">...</div> or a rule:

neokapi({
rules: [{ selector: ".hero-video-caption", translate: false }],
});

Fragments

A fragment root with inline content extracts as one block, same as a promoted container:

<>
Signed in as <strong>{user.name}</strong>.
</>

The fragment has no tag of its own, so its descriptor is the literal fragment, meaning a fragment and a <p> carrying the same words hash apart, and moving content between them is a re-key. Prefer a real element when the content is a paragraph; fragments are for the cases where the surrounding markup genuinely can't take a wrapper.

Unknown components

Component libraries like shadcn, Radix, MUI, and your own internal components render to HTML but neokapi-i18n can't know which one. By default, an unmapped React component with direct text content is extracted anyway, with a warning that suggests how to stabilise the hash:

<TabsTrigger value="general">General</TabsTrigger>
[neokapi] src/Settings.tsx:19: <TabsTrigger> is an unmapped component with
translatable text — extracted. Add a componentMap entry to stabilise
hashes: { TabsTrigger: '<underlying-html-tag>' }.
↳ <TabsTrigger value="general">General</TabsTrigger>

Adding the hint removes the warning and changes the hash from one keyed on TabsTrigger to one keyed on button:

neokapi({
componentMap: {
TabsTrigger: "button",
TabsList: "div",
DialogTitle: "h2",
},
});

Why bother? Because the hash is part of the translator's contract. If you later refactor by changing TabsTrigger → a different library's Tab, and the underlying HTML is still button, the hashes stay stable if you had the componentMap entry; translators don't need to re-review.

Translatable attributes

Two buckets, with different scopes:

BucketExtracted onNames
HTMLany elementalt, title, placeholder
ARIAany elementaria-label, aria-description, aria-placeholder, aria-roledescription, aria-valuetext
React conventionsPascalCase componentssubtitle, description, label, heading, caption, helpText, helperText, errorMessage, hint, tooltip, emptyMessage, emptyStateText, filterPlaceholder

The HTML and ARIA names are standardised: wherever they appear, they carry user-visible text. The convention names are not: label on a <Field> is copy, but label on a <div> is far more often a DOM prop, an enum key, or a data-binding field. Scoping that bucket to PascalCase components is what keeps <div label="draft-pending"> out of your translator's queue.

So these all work out of the box:

<input placeholder="Search..." aria-label="Search products" />
<img alt="Company logo" />
<button title="Save draft">💾</button>

<PageHeader title="Content Memory" subtitle="Approved terms and past translations" />
<EmptyState title="No projects yet" description="Create one to get started." />
<LoadingSpinner helpText="Contacting the server…" />
<Tooltip tooltip="Retry the last operation" />

Each attribute becomes its own translatable block.

Ternary attribute values

When a translatable attribute's value is a ternary with both branches as plain string literals, each branch extracts as its own block:

<PageHeader title={isProjectMode ? "Project Flows" : "Flows"} />

Both "Project Flows" and "Flows" get extracted (with ::0 / ::1 suffixes on the context to keep the hashes distinct). At runtime the transform rewrites each literal branch with its own __t() lookup; the condition still fires at render time.

Mixed-shape ternaries (one literal, one computed, or both templates) aren't statically extractable; the lint rule no-ternary-in-translatable-attr flags them. Fix by wrapping both branches with t() so the t-call walker picks them up:

// ✗ extractor can't see the template-literal branch
<Input placeholder={disabled ? `Disabled (${reason})` : "Enabled"} />

// ✓ both branches flow through the t() extraction path
<Input placeholder={disabled ? t("Disabled ({reason})", { reason }) : t("Enabled")} />

Non-translatable elements

These render text-as-text, not natural language, so their contents never enter the catalog:

<code>, <pre>, <kbd>, <var>, <samp>, <script>, <style>, <textarea>.

<code>npm install @neokapi/i18n-react</code> // ✗ not extracted
<pre>{licenseText}</pre> // ✗ not extracted

To flip one specific site: <code translate="yes">...</code>.

Code spans inside prose

<code>, <kbd>, <samp> and <var> are also phrasing elements: a sentence mentioning a flag or a format id is one sentence, and cutting it at the span would leave the reader half a paragraph in their own language. So the parent extracts as one block, the span becomes a paired marker like any other inline element, and the text between the tags is marked protected:

<p>
Say <code>json</code> for the faithful readers.
</p>

The block reads "Say {=m0}json{/=m0} for the faithful readers.", and its runs carry json with a do-not-translate flag. Translation, an AI pass and the qps pseudo-locale all leave it alone, so the reader gets the bytes you wrote while the prose around it is translated.

An element holding a code span and nothing else has no prose in it, so it stays out of the catalog:

<p>
<code>kapi up</code>
</p>
// ✗ no block: the element holds an identifier only

translate="yes" on the span opts its text back in, inside a sentence or on its own.

The compiled call site carries these answers, so the runtime knows them too: runtime pseudo mode accents the prose and leaves the command alone, matching what a qps catalog holds.

Controls inside prose

HTML5 lets a control sit in the middle of a sentence, and a call to action is usually written that way:

<p>
<button onClick={run}>Try it live</button> lists the registered formats.
</p>

The paragraph extracts as one block reading "{=m0}Try it live{/=m0} lists the registered formats.". The button is a paired marker, and the words inside it stay the translator's: a code span protects its text, a control's label is prose. At render time the runtime clones the button with the translated label as its children, so the handler, the type and the classes are the ones you wrote.

The controls this covers are <button>, <label>, <select>, <input>, <output>, <img>, <audio>, <video>, <meter> and <progress>.

Where the control is all its parent holds, the parent stays out and the control keeps its own block:

<div className="toolbar">
<button>Save</button> // ✓ one block: "Save"
</div>

<div>
Autosave is on. <button>Save now</button> // ✓ one block for the sentence
</div>

That is what keeps a toolbar of buttons a message each, rather than one message holding every label in the row.

Attributes on an inline child

The words between an inline child's tags belong to the sentence around it. Its attributes do not: the call site carries the element with its own props, so copy in an alt, a title, an aria-label, a placeholder or a component's label prop is a block of its own.

<p>
Use <abbr title="Content memory">CM</abbr> for that.
</p>

Two blocks: "Use {=m0}CM{/=m0} for that." and "Content memory". The same holds for a control that carries all its copy in an attribute, so an icon button or an image keeps the prose around it:

<p>
Press <button aria-label="Run the demo"><Play /></button> to start.
</p>
// two blocks: "Press {=m0} to start." and "Run the demo"

The attribute's key reads abbr[title], button[aria-label], Badge[label], and so on. It names the element and the attribute and nothing else, so a string keeps its key whether the element stands alone or sits in a sentence, and wrapping prose around an existing element does not orphan its translation. Nesting goes as deep as the markup does: <a title="…"><img alt="…" /></a> inside a paragraph is three blocks. An element marked translate="no" keeps its attributes out of the catalog along with its text.

Conditional JSX inside a sentence

A conditional renders an element or nothing, so the sentence around it cannot carry its words:

<p>Saved {unsaved && <span className="badge">with changes pending</span>} just now</p>

The paragraph extracts as "Saved {=m0} just now" with {=m0} standing for the whole conditional, and the badge extracts as a second block reading "with changes pending". The translator gets both, and the badge is rendered through its own lookup, so a translation reaches the reader whether or not the condition holds.

This covers every expression that can carry JSX: &&, ||, ??, a ternary (each branch extracts separately), a .map() over a list, and a call taking an element as an argument. It applies to attributes and t() calls inside the conditional as well:

<p>Saved {unsaved && <button aria-label="Discard changes"><X /></button>} just now</p>

The same holds for JSX passed in a prop, whether or not the element around it extracts:

<div actions={<Button>Publish</Button>}>Ready to go.</div>
// two blocks: "Ready to go." and "Publish"

What a conditional cannot rescue is a bare string literal in a branch ({cond ? "A" : "B"}), which stays opaque; see Ternary with string literals as JSX children.

An element in a plain expression

A block lifts every sibling expression into a named placeholder, and the name is all the translator sees:

<div className="relative">
{progressSegments}
<span>{progress}% ({done}/{total} translated)</span>
</div>

The <div> is one block reading "{progressSegments}{=m0}{progress}% ({done}/{total} translated){/=m0}". Four placeholders and one paired marker, all movable.

What each placeholder holds is settled at render time, not at build time. {progress} is a number, {progressSegments} is a <div> of coloured bars, and both are plain identifiers in the source. So the runtime asks the value: React content renders as itself wherever its token sits, and everything else substitutes as text with React's own rules, where null, undefined and a boolean contribute nothing.

That covers the common shapes without any ceremony:

<div>{icon} Save changes</div>
<p>Filter by {statusChips} or clear them all.</p>
<li>{rows.map((r) => <Row key={r.id} {...r} />)} in this batch</li>

Two things follow. A translator may move an element placeholder anywhere in the sentence, including inside a paired marker, and dropping its token drops the element. And a placeholder in a message that resolves to a string has nowhere to put an element: an aria-label, a title, a t() result. Handing one a React element logs a warning in development and renders nothing useful, so keep elements in JSX.

Literals in a conditional's branches

A conditional renders one of its branches as text:

<Button>{saving ? "Saving..." : "Save"}</Button>

Each string literal in it extracts as its own block, so this is two messages. Their keys carry the branch's slot position, Button::0 and Button::1, which is the same scheme a ternary attribute value uses. The build rewrites each literal where it stands, so the condition and everything around it are untouched.

The same holds for a logical operator, where the right side is the branch:

<div>{cond && "Folder moved"}</div>
<div>{label || "Untitled"}</div>

A slot keeps its number whatever fills it. In {cond ? getLabel() : "Save"} the literal is slot 1, and turning getLabel() into a literal later gives it slot 0 without moving "Save". A nested ternary contributes its own slots in place of the one it fills, so {a ? "A" : b ? "B" : "C"} numbers three of them.

Two branches take a different path. A JSX element is extracted as the element it is, and a template literal is one expression that extraction reads whole, so its words need t() (see Template literal in a ternary branch).

A conditional inside an element whose content is never prose is left alone, so <code>{a ? "x = 1" : "y = 2"}</code> and anything under translate="no" stay as written.

Opting out with translate="no"

Standard HTML; it works on any element and its descendants:

<h1 translate="no">API_KEY_PREFIX</h1> // ✗ not extracted

<section translate="no">
<h2>Debug payload</h2> // ✗ not extracted
<pre>{json}</pre> // ✗ not extracted
</section>

Both the extractor and every lint rule in @neokapi/i18n-react-lint walk up the ancestor chain looking for translate="no". A single marker at the top of a subtree silences everything inside; there is no need to sprinkle it on every element.

translate="no" is also the right answer when you're intentionally rendering an already-translated value (see "Module-level t() gotcha" below), or when your content is code-like and shouldn't be flagged as missing translation.

Inside a sentence, a marked child stays an island. Its text is kept out of the message and the element travels as one placeholder, so a translator sees the sentence around it and the reader sees the element exactly as you wrote it:

<div>
Saved to <span translate="no">{path}</span> just now
</div>
// message: Saved to {=m0} just now

A parent whose only text sits inside such a child carries no translatable text at all, so it stays out of the catalog:

<SimpleTooltip content={full}>
<span translate="no">
{file}:{key}
</span>
</SimpleTooltip>
// ✗ no message

Rules for recurring patterns

For patterns where you don't want to sprinkle translate="no" everywhere, use rules in your plugin config:

neokapi({
rules: [
{ selector: ".monospaced-input", translate: false },
{ selector: "[data-testid]", translate: false },
{ selector: ".legal-copy", locNote: "Must match legal-approved wording verbatim" },
],
});

Selectors: plain tag (code), class (.code-block), attribute presence ([data-testid]), or attribute value ([role="alert"]).

What still needs explicit handling

The extractor can only see what it can statically reason about. These patterns slip through; each has a canonical fix.

Strings in JS data structures

const THEMES = [
{ value: "system", label: "System" }, // ✗ not extractable
{ value: "light", label: "Light" },
];

return THEMES.map(({ value, label }) => (
<button>{label}</button> // ✗ label is an expression
));

Fix with the t() escape hatch:

const THEMES = [
{ value: "system", label: t("System") },
{ value: "light", label: t("Light") },
];

Caught by the prefer-t-for-label-props lint rule (off by default; opt in via recommendedStrict).

Dynamic label expressions

The render-side mirror of the above: {obj.label} / {item.title} rendered as JSX text. The extractor sees an expression container and emits a placeholder; the string it resolves to at runtime never becomes a translation unit.

// ✗ meta.label is invisible to extraction
<h1>{meta.label}</h1>

Fix by wrapping the source data with t() (same as "Strings in JS data structures" above). The lint rule prefer-t-for-label-expr flags the render site to prompt the refactor.

Template literal in a ternary branch

// ✗ the words inside the template never extract
<span>{count > 0 ? `Loading ${count}...` : "Idle"}</span>

A template literal is one expression, so extraction reads it whole and the words in it stay English. Wrap that branch with t() and pass the interpolation as a parameter:

<span>{count > 0 ? t("Loading {count}...", { count }) : "Idle"}</span>

The "Idle" beside it needs nothing: a plain string literal in a branch extracts on its own (see Literals in a conditional's branches). Caught by no-ternary-literals-in-jsx-child, which leaves a format-only template such as `${pct}%` alone.

Module-level t() gotcha

t() reads the active dictionary at call time. A module-level const evaluates once, at import time, typically before loadTranslations() has finished. The const freezes at the fallback language forever.

// ✗ "Utility" will still say "Utility" in pseudo.
const categoryMeta = {
utility: { label: t("Utility") },
pipeline: { label: t("Pipeline") },
};

Fix: wrap the lookup in a function that runs per render.

// ✓ each render resolves the label against the current dict.
function categoryMeta(cat: string) {
switch (cat) {
case "utility":
return { label: t("Utility") };
case "pipeline":
return { label: t("Pipeline") };
// …
}
}

function Chip({ cat }: { cat: string }) {
const meta = categoryMeta(cat);
return <span>{meta.label}</span>;
}

Double-translation: already-translated values inside translatable blocks

A subtle pattern that only shows up in pseudo. If you render a t()-resolved string as a child of an element the extractor also wraps as a block, pseudo-translation gets applied twice: the inner t() adds its markers, and the outer element's translation wraps around them:

<Button>
{meta.label} ({catTools.length})
</Button>
// Pseudo renders: ▒ ▒ Utility ▒ (32) ▒ ← two layers of wrapping

Fix: mark the outer element translate="no" so the inner t() call owns the translation.

<Button translate="no">
{meta.label} ({catTools.length})
</Button>
// Pseudo renders: ▒ Utility ▒ (32) ← the inner t() wrap is the only one

Alternative: lift the whole string into a single t() call with placeholders, but that's awkward when one half is a translated label and the other is a numeric count.

Translator notes

Attach a note to an element so translators see context when they open the block:

<button data-i18n-note="verb: to close a dialog, not 'nearby'">Close</button>

Or via a rule:

rules: [{ selector: ".legal-copy", locNote: "Legal team must review" }];

Summary: what goes where

Source patternExtracted?Notes
<h1>Hello</h1>yesstandard translatable element
<div>Hello</div>yesauto-promoted silently
<>Hello <b>you</b></>yesfragment root; descriptor is fragment
<Button><Icon/>Save</Button>yes"Save" extracts with {=m0} standalone for the icon
<TabsTrigger>Hello</TabsTrigger>yeswarning suggests componentMap
<PageHeader title="Hi" />yestitle is an HTML attribute; any element
<PageHeader title={cond ? "A" : "B"} />yesboth branches; one block each
<MyComp description="Hi" />yesdescription is a convention prop; components only
<div label="draft-pending" />noconvention prop on a plain element; not copy
<p>Click <a>here</a></p>yesone block, <a> becomes paired {=m0}…{/=m0}
<code>foo</code>nonon-translatable element
<p>Say <code>json</code> now</p>yesone block; json carried through as protected text
<p>Use <abbr title="X">CM</abbr> ok</p>yestwo blocks: the sentence, and the title
<h1 translate="no">X</h1>noexplicit opt-out (suppresses lint too)
<button>{label}</button>nobare expression; use t() on the source
<button>{obj.label}</button>noflagged by prefer-t-for-label-expr; wrap the source
<button>{cond ? "A" : "B"}</button>yesone block per branch, keyed button::0 / button::1
<b>{c ? `Hi ${n}` : "Bye"}</b>partly"Bye" extracts; the template needs t()
<div>{cond && "Hi"}</div>yesthe right side of the operator is a branch, keyed div::0
<p>Saved {cond && <b>a note</b>}</p>yestwo blocks: the sentence, and the conditional's own element
<div>{icon} Save changes</div>yesone block; {icon} renders as an element wherever its token sits
<div actions={<Button>Go</Button>}>Hi</div>yestwo blocks: JSX in a prop extracts as well
<p>Read <a>docs</a>{" "}now</p>yesone block; the explicit space is text, not a variable

Next