Skip to content
Nizivo UI

Strings and locales

How Nizivo's built-in strings work. Every one is in a labels prop with an English default; @nizivo/react/locales/en-US lists them all; NizivoConfig passes an app's own strings to the client parts, and a file that wraps each server part once passes them to the rest; formatLabel fills placeholders and picks CLDR plural forms.

Nizivo writes a few words of its own: accessible names ("Pagination", "Close"), status text ("Loading Revenue") and short visible text ("+4", "Show password"). It never reads the browser's language to choose them, so the server and the client always render the same words.

Labels

Each component that writes a string takes a labels prop: a plain object of strings, each one optional, with an English default. labels is serialisable, so a Server Component can pass it to any component, client components included. A placeholder in braces is filled by the component: {page} with the page number, {count} with the count, {label} with the component's own label.

<Pagination page={2} totalPages={9} labels={{ page: 'Seite {page}', next: 'Nächste Seite' }} />

Numbers in a string are formatted for the component's locale ('en-US' by default), so pass the same locale you write the strings in.

The catalogue

@nizivo/react/locales/en-US exports enUS: every built-in English string, keyed by component (enUS.pagination, enUS.avatarGroup, enUS.fileUpload, …), each typed by that component's Labels type. The module is the strings and nothing else, and the root entry does not re-export it. Read it to see every string you need to translate, or copy its shape for your own locale: a catalogue typed typeof enUS must give every string. A test keeps it equal to the components' defaults. See the entry page for its type.

Strings that React Aria renders from its own translations are not in labels and not in the catalogue: the visually hidden "Dismiss" buttons of the overlays, Select's "Select an item", NumberField's "Number field" role description and step buttons' names ("Increase Price"), and Combobox's Trigger name ("Show suggestions"), list name ("Suggestions", for a field without a label) and its announcements of the number of options. They follow the locale of the nearest NizivoConfig, and the component's Labels type names them as engine-owned (for example DialogLabels.dismiss). NumberField's labels prop can still replace its step buttons' names for one field.

Your own locale

Write your locale's strings once, in a catalogue of the enUS shape, and pass them in two ways: the client parts read them from NizivoConfig, and the server parts get them from a file that wraps each one once. A Server Component cannot read a React context, so no provider can reach the server parts; Nizivo has no module-level setting either, which would give every request on a server the same language.

// app/ui/de-DE.ts
import type { NizivoLabels } from '@nizivo/react/config';

export const deDE = {
  button: { pending: 'Wird geladen…' },
  dialog: { close: 'Schließen' },
  pagination: {
    nav: 'Seitennavigation',
    previous: 'Vorherige Seite',
    next: 'Nächste Seite',
    page: 'Seite {page}',
  },
  searchField: { label: 'Suchen', clear: 'Suche leeren' },
  toaster: { region: 'Benachrichtigungen (F8)', dismiss: 'Schließen' },
} satisfies NizivoLabels;

Once it holds every key, type it typeof enUS instead (import type { enUS } from '@nizivo/react/locales/en-US'), so a missing string is a type error. An app with an i18n library (next-intl, react-i18next, Lingui) builds the same object from its messages.

Client parts: NizivoConfig

NizivoConfig takes labels, the same shape (NizivoLabels from @nizivo/react/config: every key and every string optional, so a whole catalogue fits). Put it around the app once, with the Toaster inside it:

// app/layout.tsx
import type { ReactNode } from 'react';
import { NizivoConfig } from '@nizivo/react/config';
import { Toaster } from '@nizivo/react/toast';
import { deDE } from './ui/de-DE';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="de-DE">
      <body>
        <NizivoConfig locale="de-DE" labels={deDE}>
          {children}
          <Toaster />
        </NizivoConfig>
      </body>
    </html>
  );
}

NizivoConfig is a client component, and the catalogue is plain strings, so a Server Component layout passes it as it is; the pages under it stay Server Components. Each string resolves in this order: the part's own labels prop, key by key; then the nearest NizivoConfig's labels; then the English default. A part outside every NizivoConfig shows English. Keep the object stable (a module constant, as here): a new object on every render re-renders every part that reads it.

The provider reaches these parts:

PartCatalogue keyStrings
AlertDismissalertDismissdismiss
BannerDismissbannerDismissdismiss
ChipGroupchipGroupunavailable
Dialog.Closedialogclose
Drawer.Closedrawerclose
FileUploadfileUploadall 14 (prompt, browse, summary, …)
FormformsummaryTitle
MobileTabBarMoremobileTabBarMoremore, and close for its sheet's Close button
PasswordInputpasswordInputtoggle
Popover.Closepopoverclose
QuantityStepperquantityStepperdecrement, increment
SearchFieldsearchFieldlabel, clear
SubmitButtonbuttonpending
Toastertoasterregion, dismiss

MobileTabBarMore names its sheet's Close button with its own close, so mobileTabBarMore.close applies there, not drawer.close.

It does not reach the server parts, whose keys it ignores: AppShell (appShell), AvatarGroup (avatarGroup), Breadcrumb (breadcrumb), Button (button), KpiTile (kpiTile), Metric (metric), MobileTabBar's landmark (mobileTabBar), PageIndicator (pageIndicator), Pagination (pagination), RankedList (rankedList), Sidebar (sidebar), BulkActionBar (bulkActionBar), TableSelectAll (tableSelection) and TrendCard (trendCard). SubmitButton, Button's client twin, reads button.pending; a plain Button does not.

Server parts: wrap once

For the server parts, one app file wraps each one with the catalogue's strings, and the rest of the app imports them from there. The file has no 'use client', so the wrappers stay Server Components and ship no JavaScript; a labels prop passed to a wrapper still wins key by key.

// app/ui/nizivo.tsx
import { Button as NizivoButton, type ButtonProps } from '@nizivo/react/button';
import { Pagination as NizivoPagination, type PaginationProps } from '@nizivo/react/pagination';
import { deDE } from './de-DE';

export function Button(props: ButtonProps) {
  return <NizivoButton {...props} labels={{ ...deDE.button, ...props.labels }} />;
}

export function Pagination(props: PaginationProps) {
  return (
    <NizivoPagination locale="de-DE" {...props} labels={{ ...deDE.pagination, ...props.labels }} />
  );
}
// a page: import { Button, Pagination } from '../ui/nizivo', not from @nizivo/react
<Pagination page={2} totalPages={9} getHref={(page) => `?page=${page}`} />

Wrap AvatarGroup the same way when its count strings need plural objects in your locale (see the next section).

Plurals and formatLabel

A string whose default has a {count} placeholder can also be a plural object: one template per CLDR plural category of its locale, other always included. The count picks the category through Intl.PluralRules, so English needs one and other, Polish one, few, many and other, and Arabic all six. Today these are AvatarGroup's labels.more and labels.overflow. List exactly the categories Intl.PluralRules has for your locale, which its resolvedOptions() returns as pluralCategories: Nizivo's own catalogues are tested that way.

formatLabel from @nizivo/react/i18n is the function the components use, for your own strings too (about 0.2 KB, server-safe):

import { formatLabel, type PluralLabel } from '@nizivo/react/i18n';

const files: PluralLabel = {
  one: '{count} plik',
  few: '{count} pliki',
  many: '{count} plików',
  other: '{count} pliku',
};
formatLabel(files, { count: 3 }, 'pl'); // '3 pliki'
formatLabel(files, { count: 1250 }, 'pl'); // '1250 plików'
formatLabel('Remove {name}', { name: 'q3.pdf' }); // 'Remove q3.pdf'

values.count chooses the plural form; each {name} is replaced by values.name, a number formatted with Intl.NumberFormat(locale) and a string as written; a placeholder with no value stays as written. There are no exact-number keys ('=0'): write the zero case in your own code. Strings are never joined from pieces, so a translation can put the count anywhere.

On this page