Components
Ready-to-use React components for language selection and suggestion.
Each component has its own sub-path under @workspace/i18n/components/* (the root @workspace/i18n entry does not export components). Apps must also import @workspace/i18n/styles.css so Tailwind picks up the component classes — see Usage.
LanguageSelect
A dropdown for selecting a language, built on the shared Select component from @workspace/ui.
import { LanguageSelect } from '@workspace/i18n/components/language-select';
import { getLanguageSelectorOptions } from '@workspace/i18n/validation';
const SUPPORTED = ['fr', 'en'] as const;
const languages = getLanguageSelectorOptions(SUPPORTED);
<LanguageSelect
languages={languages}
value={currentLang}
onValueChange={setLanguage}
/>Demo
Display Modes
The display prop controls what appears in the trigger button:
// Default — shows language name
<LanguageSelect display="label" ... />
// Globe icon only (label is sr-only for accessibility)
<LanguageSelect display="icon" ... />
// Both icon and language name
<LanguageSelect display="icon-and-label" ... />Props
| Prop | Type | Default | Description |
|---|---|---|---|
languages | readonly LanguageSelectorOption<T>[] | — | Available languages. Use getLanguageSelectorOptions() to generate |
value | T | undefined | — | Currently selected language code (undefined shows the placeholder) |
onValueChange | (value: T) => void | — | Callback when language changes |
display | "label" | "icon" | "icon-and-label" | "label" | What to display in the trigger |
placeholder | string | — | Placeholder text when no value (display="label" only) |
disabled | boolean | — | Disables the select |
| ...rest | ComponentProps<typeof SelectTrigger> | — | Any other prop (className, id, aria-label, …) is spread on the SelectTrigger |
There is no default accessible name: pass aria-label when the select has no visible <label>, or id to pair it with one.
<LanguageSelect
aria-label={label}
className="w-[140px]"
languages={ALL_LANGUAGE_SELECTOR_OPTIONS}
onValueChange={onLocaleChange}
value={currentLocale}
/>LanguageDrawer
Mobile counterpart of LanguageSelect: an icon-only globe button that opens a bottom drawer listing the languages. Selecting the current language only closes the drawer; onValueChange fires only on an actual change.
import { ALL_LANGUAGE_SELECTOR_OPTIONS } from '@workspace/i18n';
import { LanguageDrawer } from '@workspace/i18n/components/language-drawer';
<LanguageDrawer
label={label}
languages={ALL_LANGUAGE_SELECTOR_OPTIONS}
onValueChange={onLocaleChange}
value={currentLocale}
/>apps/web swaps between the two at the lg breakpoint with createSwappable: LanguageSelect on desktop, LanguageDrawer on mobile.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
languages | readonly LanguageSelectorOption<T>[] | — | Available languages |
value | T | undefined | — | Currently selected language code (marked with a check) |
onValueChange | (value: T) => void | — | Callback when a different language is picked |
label | string | — | Accessible name of the trigger and title of the drawer |
className | string | — | Additional class for the trigger button |
disabled | boolean | — | Disables the trigger |
LanguageBanner
A banner that suggests switching to the user's preferred language, detected via detectPreferredLanguage.
import { LanguageBanner } from '@workspace/i18n/components/language-banner';
import { ALL_LANGUAGE_CODES } from '@workspace/i18n/constants';
// URL-based routing (apps/web)
<LanguageBanner
currentLanguage={locale}
supportedLanguages={ALL_LANGUAGE_CODES}
onSwitchLanguage={(lang) => router.push(`/${lang}${pathname.slice(3)}`)}
/>
// i18next-based (apps/app)
<LanguageBanner
currentLanguage={i18n.language as 'fr' | 'en'}
supportedLanguages={['fr', 'en']}
onSwitchLanguage={(lang) => i18n.changeLanguage(lang)}
/>Behavior
- Compares
currentLanguagewithdetectPreferredLanguage(supportedLanguages, currentLanguage) - If different, displays the banner suggesting the detected language
- Switch button — calls
onSwitchLanguage(detectedLanguage), label is shown in the suggested language - Stay button — saves preference to localStorage (won't show again), calls
onStayInCurrentLanguage - Dismiss (X) button — closes without saving (banner reappears on next visit)
Demo
Current: English — The banner suggests switching to French.
Custom Labels
<LanguageBanner
currentLanguage="en"
supportedLanguages={['fr', 'en']}
onSwitchLanguage={handleSwitch}
labels={{
message: (langName) => `We detected you prefer ${langName}`,
stayIn: (langName) => `Continue in ${langName}`,
}}
/>The "Switch" button always displays in the suggested language using built-in translations (not customizable via labels).
Props
LanguageBannerProps<T extends LanguageCode>, exported from @workspace/i18n/types:
| Prop | Type | Default | Description |
|---|---|---|---|
currentLanguage | T | — | Current language of the application |
supportedLanguages | readonly T[] | — | Languages supported by the app (subset of ALL_LANGUAGE_CODES) |
onSwitchLanguage | (lang: T) => void | — | Called with the detected language when the user clicks Switch |
onStayInCurrentLanguage | () => void | — | Called after Stay has saved the preference |
labels | LanguageBannerLabels | English defaults | Custom message and stayIn label functions |
className | string | — | Additional class for the banner container |
LanguageBannerLabels:
| Key | Type | Default |
|---|---|---|
message | (preferredLangName: string) => string | `It looks like you prefer ${langName}` |
stayIn | (langName: string) => string | `Stay in ${langName}` |
Localized
Compound component that shows content only in the languages you target. Localized.Root declares the supported languages and the ones to display; Localized.Value renders its children only when its lang is displayed; Localized.Unmatched renders a fallback when none of the content's languages is displayed.
import { Localized } from '@workspace/i18n/components/localized';
<Localized.Root supported={['fr', 'en', 'de']} toDisplay={appLanguage}>
{Object.entries(labels).map(([lang, text]) => (
<Localized.Value key={lang} lang={lang}>
{text}
</Localized.Value>
))}
<Localized.Unmatched availableLanguages={Object.keys(labels)}>
{fallback}
</Localized.Unmatched>
</Localized.Root>| Part | Props | Description |
|---|---|---|
Localized.Root | supported: string | string[], toDisplay?: string | string[] | Provides the context; toDisplay defaults to supported. Renders a div[data-slot="localized-root"] |
Localized.Value | lang: string | Renders a div[data-slot="localized-value"] when lang is in toDisplay, nothing otherwise |
Localized.Unmatched | availableLanguages?: string[] | Always renders a div[data-slot="localized-unmatched"], with hidden when one of availableLanguages is in toDisplay. Without availableLanguages it is never hidden, so parent CSS (e.g. has-data-[slot=localized-unmatched]:…) controls visibility |
useLocalized() returns the Root context ({ supported, toDisplay }, both normalized to arrays) and throws when called outside Localized.Root.