SayKit
Core Concepts

Messages

Authoring translatable text, templates, plurals, ordinals, select, and descriptors

SayKit's authoring API is small. Almost everything you write is one of:

  • a say`...` tagged template
  • a say.plural(...), say.ordinal(...), or say.select(...) call
  • a <Say>...</Say> JSX element (with <Say.Plural>, <Say.Ordinal>, <Say.Select>)

These are all macros. A SayKit build-tool plugin rewrites them into runtime calls during the build. They look like normal expressions at the call site, but everything you put in them is extracted and translated.

Macros work only when paired with a SayKit plugin (unplugin-saykit or babel-plugin-saykit). Without one, runtime calls like say.plural(...) will throw at runtime.

Basic messages

say`Hello, world!`;
say`Hello, ${name}!`;
say`Your order of ${quantity} ${item} is ready!`;

Extracted as ICU MessageFormat:

Hello, world!
Hello, {name}!
Your order of {quantity} {item} is ready!

The transform does five things:

  1. Reads the literal text and placeholders out of the template.
  2. Converts them to ICU MessageFormat.
  3. Generates a stable 6-character hash id from the message text + context.
  4. Records the file and line as a translator reference.
  5. Replaces the source expression with a say.call({ id, ...values }) call.

SayKit looks for the identifier say when transforming messages. Anything named say works , say, interaction.say, useSay(), as long as the local binding is called say.

Placeholder names

Placeholder names come from the identifier SayKit sees at the call site. Plain identifiers become named placeholders; complex expressions fall back to positional placeholders.

const name = user.profile.name ?? 'Anonymous';
say`Signed in as ${name}`; // → "Signed in as {name}"

say`Signed in as ${user.profile.name ?? 'Anonymous'}`; // → "Signed in as {0}"

Pull values into local variables before interpolating them, translators get readable placeholders, and the message stays stable across refactors.

Plurals

Branch on a count using CLDR plural categories:

say.plural(quantity, {
  one: 'You have 1 item',
  other: 'You have # items',
});

Extracted:

{quantity, plural,
  one {You have 1 item}
  other {You have # items}
}

The # placeholder is replaced with the numeric value at runtime.

All CLDR categories are supported:

say.plural(count, {
  zero: 'No items',
  one: '1 item',
  two: '2 items',
  few: 'A few items',
  many: 'Many items',
  other: '# items',
});

You can also branch on exact numbers:

say.plural(count, {
  0: 'No items',
  1: 'One item',
  other: '# items',
});

Plural categories vary by locale. English uses one and other, while Arabic uses all six. The runtime picks the right branch based on the active locale.

Ordinals

Numbers like "1st", "2nd", "3rd":

say.ordinal(position, {
  1: '#st',
  2: '#nd',
  3: '#rd',
  other: '#th',
});

CLDR categories work here too:

say.ordinal(position, {
  one: '#st',
  two: '#nd',
  few: '#rd',
  other: '#th',
});

Select

Branch on an arbitrary string:

say.select(gender, {
  male: 'He is online',
  female: 'She is online',
  other: 'They are online',
});

Extracted:

{gender, select,
  male {He is online}
  female {She is online}
  other {They are online}
}

Descriptors

A descriptor is an object passed to say before the template. It carries metadata that affects extraction.

Custom ids

By default ids are content-hashed. Provide your own to get a stable, semantic id:

say({ id: 'greeting.hello' })`Hello!`;
say({ id: 'button.submit' })`Submit`;

Custom ids are useful when:

  • you want stable ids across major refactors
  • translators rely on human-readable ids
  • you share messages between projects

Context

Identical source strings can mean different things. Context disambiguates them so translators can pick the right wording:

say({ context: 'direction' })`Right`;
say({ context: 'correctness' })`Right`;

Both extract as "Right", but they get distinct ids and live as separate entries in the catalogue. Translators see the context and can translate accordingly.

say({ context: 'noun' })`Post`;
say({ context: 'verb' })`Post`;

Translator comments

Comments on a // TRANSLATORS: line above a message get attached to the entry in the catalogue:

// TRANSLATORS: This appears on the login button
say`Sign in`;

// TRANSLATORS: Username field label in the registration form
say`Username`;

In a PO file:

#. This appears on the login button
msgid "Sign in"
msgstr "Sign in"

Use them when:

  • the meaning of a message isn't obvious from the text
  • placeholders need explanation ("# is the user's score")
  • tone or length constraints matter ("Keep under 20 characters")
  • the message references a specific UI element

JSX

In React, write messages as JSX with the <Say> component:

<Say>Hello, {name}!</Say>

<Say>
  {items} items &middot; total <strong>{total}</strong>
</Say>

JSX elements inside <Say> are preserved across translations as numbered tags:

{items} items · total <0>{total}</0>

Translators can move <0>...</0> around to fit the target language's grammar, and SayKit re-renders it with the original element at runtime.

<Say> also exposes sub-components for plurals, ordinals, and select:

<Say.Plural _={count} one="# item" other="# items" />

<Say.Ordinal _={position} _1="1st" _2="2nd" _3="3rd" other="#th" />

<Say.Select _={gender} male="He liked it" female="She liked it" other="They liked it" />

Branch keys that start with a digit get a leading underscore in JSX (_1, _2) because JSX prop names can't start with a number. SayKit strips the underscore at extract time.

See React integration for the full setup.

Best practices

Disambiguate reused words

When the same string means different things, add context so translators can pick the right wording.

say({ context: 'noun' })`Post`;
say({ context: 'verb' })`Post`;

Leave notes for translators

Add comments when meaning, tone, or UI constraints would surprise a translator.

// TRANSLATORS: Button text, keep under 20 characters
say`Continue`;

Prepare values first

Lift values into local variables so placeholders stay readable.

const name = user.profile.name ?? 'Anonymous';
say`Hello, ${name}`;

Keep messages whole

Don't split messages across string concatenation, translators need full sentences.

say`Hello, ${name}`; // ✓
say`Hello, ` + name; // ✗

Next

  • Runtime, loading and activating locales, formatting at runtime
  • Extraction, running the CLI, watch mode, reconciliation
  • React, <Say> in detail

On this page