Writing translatable components
Most JSX you already write extracts as it stands. Below are the rules the plugin applies, the warnings it gives when it makes a judgement call, and the few patterns that hide text from extraction and need an explicit fix.
Rules at a glance
- 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
componentMapentry. - 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 usually hold DOM props or enum keys. - 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. - Non-translatable elements on their own (
<code>,<pre>,<kbd>,<var>,<script>,<style>,<textarea>) → skipped. - Elements marked
translate="no"(or any ancestor) → skipped.
Rules in 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 becomes a paired marker, whatever its content: text, an expression, an icon or nested elements. An empty inline element becomes a standalone marker instead. Some examples:
| Source | Extracted 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 text in it, such as {"!"}, becomes a placeholder like any other
expression.
Empty inline elements as standalone markers
Much React UI looks like <Button><Icon />Open File...</Button>, an icon component followed by text. An inline element with no children becomes a single standalone marker, and the text around it extracts as usual:
<Button>
<FolderOpen size={12} />
Open File...
</Button>
This extracts as "{=m0} Open File...", with {=m0} bound to the <FolderOpen /> element (standalone, with no matching {/=m0} close). The same applies to Radix icons, lucide-react, Heroicons and custom <Spinner /> components: any element with no children.
An unmapped React component with children counts as block-level by default, and the warning suggests a componentMap entry (see "Unknown components" below). Because the marker rule covers only unmapped components with no children, a custom block-level component such as <Panel><Heading>…</Heading></Panel> stays out of the sentence around it.
Auto-promoted containers
Strict W3C semantics classify <div> as a container and would skip <div>Hello</div>. React codebases put text in <div>Label</div> and <section>Intro copy</section> all the time, so neokapi-i18n auto-promotes a container element when it has:
- At least one direct non-whitespace JSXText child, AND
- Only inline children (no nested block-level elements).
Promotion is silent. <div>Label</div> is so common that a warning on each one would bury the warnings that need action. 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. A fragment and a <p> carrying the same words therefore hash apart, and moving content between them changes its key. Use a real element when the content is a paragraph, and a fragment where the surrounding markup can't take a wrapper.
Unknown components
Component libraries like shadcn, Radix and MUI, and your own internal components, render to HTML elements that neokapi-i18n can't see from the call site. An unmapped React component with direct text content extracts 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 map it? The hash is part of the translator's contract. With a componentMap entry, a later refactor from TabsTrigger to another library's Tab keeps every hash, as long as the underlying HTML is still button, and translators have nothing to re-review.
Translatable attributes
Two buckets, with different scopes:
| Bucket | Extracted on | Names |
|---|---|---|
| HTML | any element | alt, title, placeholder |
| ARIA | any element | aria-label, aria-description, aria-placeholder, aria-roledescription, aria-valuetext |
| React conventions | PascalCase components | subtitle, description, label, heading, caption, helpText, helperText, errorMessage, hint, tooltip, emptyMessage, emptyStateText, filterPlaceholder |
The HTML and ARIA names are standardised, and wherever they appear they carry user-visible text. The convention names vary: label on a <Field> is copy, while label on a <div> is more often a DOM prop, an enum key or a data-binding field. Because that bucket applies to PascalCase components only, <div label="draft-pending"> stays out of your translators' queue.
These all extract as written:
<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 elements hold code or raw text, so their contents stay out of 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
that mentions a flag or a format id is one sentence, and cutting it at the span
would leave half the paragraph untranslated. The parent therefore 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 are for the translator: a code span's text is
protected, and 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>
A toolbar of buttons therefore gives one message per button.
Attributes on an inline child
The words between an inline child's tags belong to the sentence around it. Its
attributes are separate. 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 becomes 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"
A bare string literal in a conditional branch ({cond ? "A" : "B"}) extracts as
a block of its own, inside a sentence or alone. See Literals in a conditional’s branches.
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.
The runtime decides what each placeholder holds at render time.
{progress} is a number, {progressSegments} is a <div> of coloured bars,
and both are plain identifiers in the source, so the runtime checks 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:
<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>
The extractor and every lint rule in @neokapi/i18n-react-lint walk up the ancestor chain looking for translate="no", so one marker at the top of a subtree covers everything inside it.
Use translate="no" also when you render an already-translated value on purpose (see "Module-level t() gotcha" below), or when the content is code-like and should not be flagged as a missing translation.
Inside a sentence, a marked child is kept whole. Its text stays 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 has nothing to translate, so it stays out of the catalog:
<SimpleTooltip content={full}>
<span translate="no">
{file}:{key}
</span>
</SimpleTooltip>
// ✗ no message
Rules for recurring patterns
For a pattern that recurs across many files, add a rule to your plugin config instead of marking each element:
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 reads only what it can analyse statically. The patterns below hide text from it, and each has a standard 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 prefer-t-for-label-expr lint rule 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, so it keeps the fallback-language text for the life of the page.
// ✗ "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
This shows up in pseudo-translation. If you render a t()-resolved string as a child of an element that the extractor also treats as a block, pseudo-translation applies 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
You can also lift the whole string into a single t() call with placeholders. That is awkward when one half is a translated label and the other 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 pattern | Extracted? | Notes |
|---|---|---|
<h1>Hello</h1> | yes | standard translatable element |
<div>Hello</div> | yes | auto-promoted silently |
<>Hello <b>you</b></> | yes | fragment root; descriptor is fragment |
<Button><Icon/>Save</Button> | yes | "Save" extracts with {=m0} standalone for the icon |
<TabsTrigger>Hello</TabsTrigger> | yes | warning suggests componentMap |
<PageHeader title="Hi" /> | yes | title is an HTML attribute; any element |
<PageHeader title={cond ? "A" : "B"} /> | yes | both branches; one block each |
<MyComp description="Hi" /> | yes | description is a convention prop; components only |
<div label="draft-pending" /> | no | convention prop on a plain element; not copy |
<p>Click <a>here</a></p> | yes | one block, <a> becomes paired {=m0}…{/=m0} |
<code>foo</code> | no | non-translatable element |
<p>Say <code>json</code> now</p> | yes | one block; json carried through as protected text |
<p>Use <abbr title="X">CM</abbr> ok</p> | yes | two blocks: the sentence, and the title |
<h1 translate="no">X</h1> | no | explicit opt-out (suppresses lint too) |
<button>{label}</button> | no | bare expression; use t() on the source |
<button>{obj.label}</button> | no | flagged by prefer-t-for-label-expr; wrap the source |
<button>{cond ? "A" : "B"}</button> | yes | one 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> | yes | the right side of the operator is a branch, keyed div::0 |
<p>Saved {cond && <b>a note</b>}</p> | yes | two blocks: the sentence, and the conditional's own element |
<div>{icon} Save changes</div> | yes | one block; {icon} renders as an element wherever its token sits |
<div actions={<Button>Go</Button>}>Hi</div> | yes | two blocks: JSX in a prop extracts as well |
<p>Read <a>docs</a>{" "}now</p> | yes | one block; the explicit space is text, not a variable |
Related
t()escape hatch: for strings in JS data and template literals in a branch.- Plurals and select: count-aware and choice-based text.
- Linting: editor squigglies for every anti-pattern on this page.
- Extract pipeline: how the plugin ships this to translators.