In the beginning of my career as a software engineer, I worked on several niche B2B products that only dealt with end-users who spoke English and were based in the US. In 2018, that all changed when I got the exciting opportunity to pick up my life and move to Barcelona to work for Typeform. Suddenly, not only was I working with a diverse team of other frontend developers from dozens of different countries, but I was building a product that needed to serve users around the world. From there on, I moved to work at an international financial exchange, where we needed to provide financial data, times and dates, and text content in the language and format that our users were accustomed to, with more than 10 languages supported.
In working with such diverse teams on products that needed to support users around the world, I learned a thing or two about making a React application feel like home for users across the globe. In this post, I'd like to share what I've learned.
Dealing with Dates and Times#
It's hard to overstate the importance of localizing dates and times. To a person from the US, 01/02/2022 means January 2nd, 2022. To someone from Australia, that would mean February 1st, 2022. In addition to differences in terms of layout, there are differences in separators, different words, and different clock cycles that are used in different locales.
How could this become problematic? An example: a US-based company's web app tells global users that an important, action-required step needs to be taken by 01/02/2022. From a quick glance, a European or Australian user reads this as February 1st (instead of the intended January 2nd) and simply forgets about it until receiving notice in their email on January 3rd that they failed to perform the required steps by the deadline.
Localizing dates used to be painful, and could involve generating different bundles for different locales, as the locale data was quite heavy in libraries like Moment, for example. Fortunately, according to CanIUse, browser support for Intl.DateTimeFormat is greater than 97% at the time of writing. This means that most applications should be able to leverage the browser's date localization capabilities without needing to depend on heavy third-party dependencies.
The best way I've found to handle locale-aware date and time formatting is to create a simple component that leverages Intl.DateTimeFormat
:
import React, { BaseHTMLAttributes } from 'react';
interface Props extends BaseHTMLAttributes<HTMLTimeElement>, Intl.DateTimeFormatOptions {
fractionalSecondDigits?: 1 | 2 | 3;
value?: string | number | Date;
locale?: string;
}
const getDateString = (value: Props['value']): string | undefined => {
if (!value) {
return undefined;
}
try {
const valueAsDate = new Date(value);
return valueAsDate.toISOString();
} catch {
return undefined;
}
};
const safelyFormatValue = (formatter: Intl.DateTimeFormat, value: Props['value']): string => {
if (!value) {
return '';
}
try {
const dateFromValue = new Date(value);
return formatter.format(dateFromValue);
} catch {
return '';
}
};
export const FormatDate: React.FC<Props> = ({
fractionalSecondDigits,
hour,
minute,
day,
month,
year,
second,
value,
timeZoneName,
locale = 'default',
...rest
}) => {
const dateFormatter = new Intl.DateTimeFormat(locale, {
fractionalSecondDigits,
hour,
minute,
day,
month,
year,
second,
timeZoneName,
});
return (
// The dateTime attribute shows the ISO date being formatted with a quick DOM inspection
<time dateTime={getDateString(value)} {...rest}>
{safelyFormatValue(dateFormatter, value)}
</time>
);
};
The component code above was used for the date you see in the "Try it" section above. It is used like so:
<FormatDate day="2-digit" month="2-digit" year="numeric" value={new Date()} />
See the full documentation for more available options.
Relative Time#
Many applications leverage relative times, for example, User commented 3 minutes ago
or Livestream starting in 42 seconds
.
Fortunately for us, the browser offers a solution for this as well: Intl.RelativeTimeFormat.
As with Intl.DateTimeFormat
, I've found it easiest to create a simple component to leverage Intl.RelativeTimeFormat
:
import React, { BaseHTMLAttributes } from 'react';
/**
* style is omitted from Intl.RelativeTimeFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.RelativeTimeFormatOptions, 'style'> {
formatStyle?: Intl.RelativeTimeFormatStyle;
unit: Intl.RelativeTimeFormatUnit;
value: number;
locale?: string;
}
export const FormatRelativeTime: React.FC<Props> = ({ formatStyle, locale = 'default', numeric, unit, value, ...rest }) => {
const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { style: formatStyle, numeric });
return (
// The unit and value data attributes make debugging easier with a quick DOM inspection
<span data-unit={unit} data-value={value} {...rest}>
{relativeTimeFormatter.format(value, unit)}
</span>
);
};
The component code above was used for the date you see in the "Try it" section above. It is used like so:
5 minutes ago
<FormatRelativeTime unit="minutes" value={-5}/>
In 10 seconds
<FormatRelativeTime unit="seconds" value={10}/>
See the full documentation for more available options.
Dealing with Numbers#
Just as with dates, different locales handle number and currency formatting differently. For example, in the US, we use the comma as the thousands separator (1,024) and the period as the decimal separator (3.14). In much of Europe, the period is used as the thousands separator (1.024) and the comma is used as the decimal separator (3,14). Intl.NumberFormat has excellent browser support and can deal with currency formatting, percentage formatting, various notations, and more.
As with date and time formatting, I've found it easiest to create a simple component to leverage Intl.NumberFormat
:
import React, { BaseHTMLAttributes } from 'react';
/**
* style is omitted from Intl.NumberFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.NumberFormatOptions, 'style'> {
formatStyle?: Intl.NumberFormatOptionsStyle;
locale?: string;
value?: number;
}
export const FormatNumber: React.FC<Props> = ({
currency,
currencySign,
useGrouping,
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
minimumSignificantDigits,
maximumSignificantDigits,
formatStyle,
locale,
value,
...rest
}) => {
const numberFormatter = new Intl.NumberFormat(locale, {
style: formatStyle,
currency,
currencySign,
useGrouping,
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
minimumSignificantDigits,
maximumSignificantDigits,
});
return (
// The number data attribute makes debugging easier with a quick DOM inspection
<span data-number={value?.toString()} {...rest}>
{value === undefined || Number.isNaN(value) ? '' : numberFormatter.format(value)}
</span>
);
};
Note: if your application needs to support formatted cryptocurrency prices or values, Bitcoin is actually supported by the
Intl.NumberFormat
API with the currency code BTC
, but many lesser known coin projects are not. Check out my post on
working with currency values in
TypeScript for a solution to that.
See the full documentation for more available options.
More from Intl#
The Intl
object has been under development in recent years, and there are several additional features worth exploring:
- Intl.Collator is useful for language-sensitive string comparison (namely sorting). For example, this factors in diacritics and other characters (e.g., ö, á, ß, ж, ぉ).
- Intl.ListFormat is useful for formatting groups of items into a string format. For example, German and several other languages do not use the Oxford comma, while English does.
- Intl.PluralRules is useful for pluralizing items, as pluralization rules differ between locales. It can also be used for ordinal values (e.g., 1st, 3rd, 5th).
- Intl.Segmenter is
useful for splitting a string in a locale-sensitive manner. Did you know that there are languages (like Japanese, Chinese,
and Thai) that don't use whitespace between words? This means that
String.prototype.split(' ')
will not actually provide an array of words in the sentence in these languages. Browser support forIntl.Segmenter
is ok, but is notably missing Firefox support at the time of writing.
Note that some of this functionality can also be found in the i18n library I recommend in the next section.
Translation#
As of 2022, there are 1.453 billion people in the world who speak English, of which, only about 26% are native English speakers. With a global population of ~8 billion, that means only ~18% of the global population speaks English. There's a clear business case here for many applications to support additional languages.
Next, I'm going to break down how to serve and manage translations.
Serving Translations#
While I believe there is a lot of valid criticism for using a library for everything in the JavaScript ecosystem, I strongly believe this is a case where you want to put your trust in the experts of the open-source community. The most common library I've seen for serving translations in a React app is react-i18next.
Initialize i18next#
First, decide how you want to serve your translations. They can be bundled into your app's entrypoint bundle (probably not recommended),
loaded as JSON via HTTP request from your public
directory, or loaded via a third-party service. To get started, I would
recommend serving from your public
directory via the HTTP backend, but you can find a list of available backends here.
Next, install the dependencies (substituting in your preferred backend as applicable):
yarn add react-i18next i18next i18next-chained-backend i18next-http-backend i18next-localstorage-backend i18next-browser-languagedetector
i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-chained-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';
import HTTPBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
const backends = process.env.ENVIRONMENT === 'development'
// in dev, always pull the latest translations, as they could be updating constantly
? [HTTPBackend]
// in production, attempt to get locale storage-cached translations before hitting HTTP backend
: [LocalStorageBackend, HTTPBackend];
i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
backend: {
backends,
backendOptions: [
{
prefix: '@@i18n_',
expirationTime: 7 * 24 * 60 * 60 * 1000 * 7, // 1 week by default,
store: window.sessionStorage,
defaultVersion: process.env.CI_COMMIT_SHA, // This could be your app version
},
{
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
],
},
supportedLngs: ['en-US', 'es-MX'],
load: 'all',
fallbackLng: 'en-US',
ns: ['common'],
defaultNS: 'common',
interpolation: {
escapeValue: false, // not needed for react, as it escapes by default
},
});
export default i18n;
public/locales/en-US/common.json
{
"greeting": "Hello {{name}}",
"itemsToReview_one": "You have {{count}} item to review.",
"itemsToReview_other": "You have {{count}} items to review.",
"errors": {
"userNotFound": "User not found",
"urgentNotice": "<0>URGENT!</0> There was a problem. Click <1>here</1> for help."
}
}
public/locales/es-MX/common.json
{
"greeting": "Hola {{name}}",
"itemsToReview_one": "Tienes {{count}} cosa que revisar.",
"itemsToReview_other": "Tienes {{count}} cosas que revisar.",
"errors": {
"userNotFound": "Usuario no encontrado",
"urgentNotice": "<0>¡Urgente!</0> Había un problema. Haga clic <1>aquí</1> para obtener ayuda."
}
}
The next step is to import your i18n
instance into your app's entry point (index.tsx
or the like):
import './i18n';
Once this is done, you can start using the useTranslation()
hook to replace hard-coded strings with translated ones:
import { useTranslation, Trans } from 'react-i18next';
interface Props {
error: any;
itemsToReview: number;
name: string | undefined;
}
export const Greeting: React.FC<Props> = ({ error, itemsToReview, name }) => {
const { t } = useTranslation('common');
if (error) {
return (
<div>
<Trans t={t} i18nKey="common:errors.urgentNotice">
<strong />
<a href="/support" />
</Trans>
<p>{t('common:errors.userNotFound')}</p>
</div>
);
}
return (
<div>
<span>{t('common:greeting', { name })}</span>
<span>{t('common:itemsToReview', { count: itemsToReview })}</span>
</div>
);
};
In the example above, you can see examples of a few of the key react-i18next
concepts:
common
, passed touseTranslation()
and prepended in thei18nKey
is the namespace. In order for i18next to load a namespace from your configured backend, you need to pass it touseTranslation()
, which can take an array of namespaces. Keys are always prefixed with their namespace, which by default matches the filename before.json
of the file that the translation is located in.t
, returned byuseTranslation()
is the function that handles translating simple strings that don't require interpolation. The first argument is thei18nKey
and the second is the variable object required for the key. You can also see an example of a key,common:itemsToReview
, which has different pluralization rules depending on the count. Note that some languages have more complicated pluralization rules than English.<Trans />
is used to interpolate more complex items into your translations (like React Elements). In this case, it's used to translate the support link as part of a full phrase.
I like to use an abstraction component over react-i18next
to help with debugging translation issues with peers across the business.
import React, { BaseHTMLAttributes, Suspense } from 'react';
import { Trans, useTranslation } from 'react-i18next';
interface Props extends BaseHTMLAttributes<HTMLSpanElement> {
element?:
| 'span'
| 'b'
| 'strong'
| 'em'
| 'p'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6';
i18nKey: string;
variables?: Record<string, any>;
}
const useTranslationProps = ({
children,
i18nKey,
variables,
...rest
}: Omit<Props, 'element'>) => {
const namespace = i18nKey.split(':')[0];
const { t } = useTranslation(namespace);
return {
...rest,
'data-i18n-key': i18nKey,
'children': children ? (
<Trans t={t} i18nKey={i18nKey} values={variables}>
{children}
</Trans>
) : (
t(i18nKey, variables)
),
};
};
const TextInner: React.FC<Props> = ({ element = 'span', ...rest }) => {
const props = useTranslationProps(rest);
switch (element) {
case 'b':
return <b {...props} />;
case 'strong':
return <strong {...props} />;
case 'em':
return <em {...props} />;
case 'p':
return <p {...props} />;
case 'h1':
return <h1 {...props} />;
case 'h2':
return <h2 {...props} />;
case 'h3':
return <h3 {...props} />;
case 'h4':
return <h4 {...props} />;
case 'h5':
return <h5 {...props} />;
case 'h6':
return <h6 {...props} />;
case 'span':
default:
return <span {...props} />;
}
};
export const Text: React.FC<Props> = (props) => (
// I would recommend having a higher-level Suspense with a loading spinner fallback
<Suspense fallback="[...]">
<TextInner {...props} />
</Suspense>
);
What this accomplishes is:
- It gives you a consistent interface to add translated text to your interface, regardless of interpolation.
- It adds the
data-i18n-key
data attribute to your translated text in the DOM, so your peers across the business can quickly point you to the translation key that is causing trouble, regardless of which language they're using the application in. - It ensures that the required namespaces are loaded into your application.
Simple example using key from above
<Text i18nKey="common:greeting" variables={{ name }} />
Example with interpolation using key from above
<Text i18nKey="common:errors.urgentNotice">
<strong />
<a href="/support" />
</Text>
Update the html
Tag with the Appropriate lang
Attribute#
It's important to update your application's html
tag with the appropriate lang
attribute, because it gives context to
screen readers and other assistive technologies. Here's a sample hook that does just that:
import { useEffect } from 'react';
import { useTranslation } from 'react-18next';
export const useUpdateHTMLLanguage = () => {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.setAttribute('lang', i18n.resolvedLanguage);
}, [i18n.resolvedLanguage]);
}
Translation Management#
While actually having the translations written in various locales is not likely in scope for an engineering team, I'll share what I've seen work.
The engineering team should commit to adding new translation keys for one locale. In this case, let's assume en-US
for
the sake of this example. As the engineers are working, they will add copy to the locales/en-US.json
file and carry on
with their work. When they open a pull request for their work upstream to their development
environment, a script can run
in CI that will check for newly-created keys that aren't present in all the supported locales and notify the appropriate
translators that your branch needs translation work. Alternatively, you could consider a tool like translation-check,
which will create a simple dashboard where you can get an overview of missing translations by locale.
There are also a number of third-party services that offer translation management and collaboration dashboards (Locize, Loco, Lokalise, etc.).
Layout Shifting#
It's worth noting that many times, layouts are only designed with the default language in mind, which is oftentimes English. There are a number of languages that can be more verbose than English. For example, Spanish, Portuguese, and Russian are often more space-consuming than English and can cause overflow issues for your application. Unfortunately, there's not a great way to prevent this. I would recommend naming your translation keys in a manner that is self-explanatory to the translators to ensure they have space constraints in-mind when crafting their translations.
I haven't found a great automated tool for testing overflow, but I would recommend manually testing translation additions in all languages as part of your pre-release process. Of course, the most important areas to focus on will depend on your application, but I have often seen issues with navigation overflow in both headers and footers. The layout elements are the lowest-hanging fruits.
Right-to-Left (RTL)#
While the majority of languages are written from left-to-right (LTR), there are a few prominent languages that are written from right-to-left (e.g., Arabic and Hebrew). I have not yet had to work on an application that had business requirements to support RTL, but from what I understand, there are a few key concepts to keep in mind.
Update the html
Tag with the Appropriate dir
Attribute#
The dir
attribute in the page's html
tag should be set to rtl
for right-to-left languages, otherwise ltr
, auto
, or unset.
Here's a sample hook that does just that:
import { useEffect } from 'react';
import { useTranslation } from 'react-18next';
export const useUpdateHTMLDirection = () => {
const { i18n } = useTranslation();
const dir = i18n.dir(); // Where dir will be 'ltr' or 'rtl';
useEffect(() => {
document.documentElement.setAttribute('dir', dir);
}, [dir]);
}
Note that this could be combined with the hook to update the language on the html
tag as well.
You can also use the dir attribute on structural elements throughout your page if only parts of your application need to read from right-to-left.
Flip the Layout#
The "F" layout that many websites use is great for languages that read from left-to-right, but it should be flipped for your users that are reading your website from right-to-left. This can be accomplished without much effort if you use flexbox or grid-based layouts. For example:
.flex-layout {
display: flex;
flex-direction: row;
}
.grid-layout {
display: grid;
grid-template-columns: 320px 1fr;
}
[dir="rtl"] .flex-layout {
flex-direction: row-reverse;
}
[dir="rtl"] .grid-layout {
grid-template-columns: 1fr 320px;
}
Realign the Text#
For right-to-left languages, you will likely want to flip the text alignment you have in your standard left-to-right template to better match the flipped layout. You can accomplish this fairly easily by making the following replacements:
.align-start {
// text-align: left; Before
text-align: start; // After
}
.align-end {
// text-align: right; Before
text-align: end; // After
}
Fix Spacing#
Instead of using hard *-left
and *-right
spacing values (margin and padding), you will want to use the following
replacements that take writing directionality in mind:
- padding-inline-start in place of padding-left
- padding-inline-end in place of padding-right
- margin-inline-start in place of margin-left
- margin-inline-end in place of margin-right
Further Reading: Vertical Writing#
Some languages are traditionally written vertically. I haven't worked on an application that supports vertical writing yet, but I wanted to call out that this is yet another case that might need to be accounted for, and that there is support for this in CSS via writing-mode.
Conclusion#
While the diversity in culture, tradition, and language are some of the things that make the world so beautiful, they can
cause unsuspecting engineers a lot of trouble. It would be unrealistic to expect any single engineer, or even team, to hold
all the information required to internationalize an application manually. Fortunately, it has never been easier to internationalize
and localize an application than it is today with Intl
reaching great browser support and developing rapidly. If you think there's
something I missed or got wrong, reach out to me on X and share @joshuaslate.