Bridge Training
i18n

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

PropTypeDefaultDescription
languagesreadonly LanguageSelectorOption<T>[]—Available languages. Use getLanguageSelectorOptions() to generate
valueT | 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
placeholderstring—Placeholder text when no value (display="label" only)
disabledboolean—Disables the select
...restComponentProps<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

PropTypeDefaultDescription
languagesreadonly LanguageSelectorOption<T>[]—Available languages
valueT | undefined—Currently selected language code (marked with a check)
onValueChange(value: T) => void—Callback when a different language is picked
labelstring—Accessible name of the trigger and title of the drawer
classNamestring—Additional class for the trigger button
disabledboolean—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

  1. Compares currentLanguage with detectPreferredLanguage(supportedLanguages, currentLanguage)
  2. If different, displays the banner suggesting the detected language
  3. Switch button — calls onSwitchLanguage(detectedLanguage), label is shown in the suggested language
  4. Stay button — saves preference to localStorage (won't show again), calls onStayInCurrentLanguage
  5. 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:

PropTypeDefaultDescription
currentLanguageT—Current language of the application
supportedLanguagesreadonly 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
labelsLanguageBannerLabelsEnglish defaultsCustom message and stayIn label functions
classNamestring—Additional class for the banner container

LanguageBannerLabels:

KeyTypeDefault
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>
PartPropsDescription
Localized.Rootsupported: string | string[], toDisplay?: string | string[]Provides the context; toDisplay defaults to supported. Renders a div[data-slot="localized-root"]
Localized.Valuelang: stringRenders a div[data-slot="localized-value"] when lang is in toDisplay, nothing otherwise
Localized.UnmatchedavailableLanguages?: 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.