Skip to main content

Internationalization (i18n) in product tours: RTL, plurals, and more

Learn how to internationalize product tours in React. Covers RTL tooltip positioning, ICU plurals for step counters, ARIA translation, and i18n library choices.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202611 min read
Share
Internationalization (i18n) in product tours: RTL, plurals, and more

Internationalization (i18n) in product tours: RTL, plurals, and more

Every i18n guide covers translating your app. None of them cover translating your onboarding.

That's a problem. 75% of internet users speak a language other than English, and your product tour is the first thing they interact with. A tour that says "Next" when the user reads right-to-left, or "Step 1 of 5" when Arabic requires a dual noun form, breaks trust before your product gets a chance to build it.

This guide covers the specific i18n challenges in product tours that nobody else has written about. RTL tooltip positioning, plural rules for step counters, translating ARIA labels, keyboard navigation reversal. Plus how to pick an i18n library that won't double your bundle size.

npm install @tourkit/core @tourkit/react

What is product tour i18n?

Product tour internationalization is the process of adapting onboarding flows (step content, tooltip positioning, progress indicators, button labels, accessibility announcements) for users across different languages, scripts, and reading directions. Unlike general app i18n, which primarily deals with translating static UI strings, tour i18n involves dynamic, sequenced content that interacts with tooltip placement engines and focus management systems. As of April 2026, no major product tour library (React Joyride, Shepherd.js, Driver.js, Intro.js) documents an i18n strategy in their guides, leaving teams to figure it out from scratch.

Why product tour i18n matters

Most React i18n follows a straightforward pattern: extract strings, create translation files, load the right locale at runtime. Tours add three layers of complexity on top of that baseline.

First, tooltip positioning is directional. A tooltip anchored to the "right" side of an element needs to flip to the "left" in RTL layouts. Most positioning engines handle this automatically when you use CSS logical properties. But if your tour library uses hardcoded left/right offsets (and many still do), every tooltip breaks in Arabic and Hebrew.

Second, progress indicators need plural rules. "Step 1 of 5" seems simple. Then you hit Arabic, which has six plural categories including a dual form. Polish treats numbers ending in 2-4 differently from those ending in 5-9. ICU MessageFormat handles this, but tour libraries ship progress components that ignore pluralization entirely.

Third, tour content is sequential and timed. A user reads a tooltip, processes the instruction, then clicks "Next." German text expands 30-40% compared to English. If your tooltip has a fixed width, German translations overflow. If you truncate, users miss the instruction.

RTL tooltip positioning

Right-to-left support in product tours breaks at the tooltip level. Why? Most positioning libraries default to physical CSS properties. When a tour step says placement: "right", the tooltip appears to the right of the target element. In an RTL layout, "right" is where content starts, not where it ends. The tooltip covers the element it's supposed to explain.

Floating UI handles RTL correctly when you pass rtl: true to the middleware. Tour Kit uses Floating UI under the hood, so placement mirroring works out of the box. But custom tour steps still need careful handling of arrow positioning and offset direction.

CSS logical properties fix arrow positioning

Mozilla bug #1277207 documents tooltip arrows breaking in RTL because they use left for positioning. The fix is CSS logical properties, which have full browser support in 2026.

// src/components/TourTooltipArrow.tsx
// Physical properties (breaks in RTL)
const brokenArrow = {
  left: '12px',
  top: '-6px',
};

// Logical properties (works in both directions)
const fixedArrow = {
  insetInlineStart: '12px',
  insetBlockStart: '-6px',
};

Replace every left/right/top/bottom in your tooltip styles with inset-inline-start/inset-inline-end/inset-block-start/inset-block-end. This single change handles all RTL languages without separate stylesheets. Ahmad Shadeed's RTL Styling 101 on CSS-Tricks covers the full property mapping. It's the best reference available for directional CSS.

Floating UI RTL configuration

// src/components/TourStep.tsx
import { useFloating, offset, flip, shift } from '@floating-ui/react';

function TourStep({ placement = 'right', children }) {
  const isRtl = document.documentElement.dir === 'rtl';

  const { refs, floatingStyles } = useFloating({
    placement,
    middleware: [
      offset(8),
      flip(),
      shift({ padding: 8 }),
    ],
    // Floating UI auto-mirrors placement in RTL
    // "right" becomes "left" when dir="rtl"
  });

  return (
    <div ref={refs.setFloating} style={floatingStyles}>
      {children}
    </div>
  );
}

Floating UI mirrors placement automatically based on the dir attribute on the document root. No conditional logic needed. Just make sure your root <html> element has the correct dir value.

Translating step content

Tour step content is just React components, which means any i18n library that works with React works with your tour steps. The question is which one to pick, and the answer depends on your existing stack and bundle budget.

i18n library comparison for tour projects

LibraryBundle size (min+gzip)ICU plural supportReact Server ComponentsBest for
next-intl457 BICU subsetNativeNext.js App Router projects
typesafe-i18n~1 KBCustomYesSmallest possible runtime
LinguiJS~10.4 KBFull ICUYesCompile-time extraction + type safety
react-i18next~22.2 KB (with i18next)Via pluginYesLargest ecosystem, most plugins
FormatJS / react-intl~17.8–20 KBFull ICUYesFull ICU compliance at any cost

As of April 2026, react-i18next has 3.5M+ weekly npm downloads and the largest plugin ecosystem, but carries more boilerplate than newer alternatives. FormatJS provides the most complete ICU implementation but ships at ~20KB gzipped. For tour-heavy apps where bundle size matters, LinguiJS at 10.4KB offers full ICU support with compile-time extraction.

Wiring translations to tour steps

Here's a practical pattern using react-i18next, since it's the most widely adopted. The same concept applies to any library.

// src/tours/onboarding.tsx
import { useTranslation } from 'react-i18next';
import { useTour } from '@tourkit/react';

function OnboardingTour() {
  const { t } = useTranslation('onboarding');

  const steps = [
    {
      target: '#dashboard-nav',
      title: t('steps.dashboard.title'),
      content: t('steps.dashboard.content'),
    },
    {
      target: '#create-button',
      title: t('steps.create.title'),
      content: t('steps.create.content'),
    },
  ];

  const tour = useTour({ steps });

  return <>{tour.currentStep && <TourTooltip step={tour.currentStep} />}</>;
}
// public/locales/en/onboarding.json
{
  "steps": {
    "dashboard": {
      "title": "Your dashboard",
      "content": "This is where you'll find all your projects."
    },
    "create": {
      "title": "Create a project",
      "content": "Click here to start your first project."
    }
  }
}
// public/locales/ar/onboarding.json
{
  "steps": {
    "dashboard": {
      "title": "لوحة التحكم الخاصة بك",
      "content": "هنا ستجد جميع مشاريعك."
    },
    "create": {
      "title": "إنشاء مشروع",
      "content": "انقر هنا لبدء مشروعك الأول."
    }
  }
}

Tour Kit is headless, so the translation layer sits in your code, not inside the library. You control how strings load and what fallback to use. Opinionated tour libraries that ship pre-built tooltips force you into their string handling, which rarely supports ICU plurals or namespaced translation files.

ICU plurals for step counters

"Step 1 of 5" is the most overlooked i18n problem in product tours. English has two plural forms: singular and plural. Arabic has six. Polish distinguishes between numbers ending in 2-4 and those ending in 5-9. The ICU MessageFormat specification handles all of these cases with a single syntax.

The plural categories

ICU defines six plural categories: zero, one, two, few, many, and other. Every language maps its numbers to these categories differently. The other category is always required as a fallback.

// src/components/TourProgress.tsx
import { FormattedMessage } from 'react-intl';

function TourProgress({ current, total }: { current: number; total: number }) {
  return (
    <span aria-live="polite">
      <FormattedMessage
        id="tour.progress"
        defaultMessage="Step {current} of {total}"
        values={{ current, total }}
      />
    </span>
  );
}
// English: simple cardinal
{ "tour.progress": "Step {current} of {total}" }

// Arabic: uses dual form for 2
{
  "tour.progress": "{total, plural, =1 {الخطوة {current} من خطوة واحدة} =2 {الخطوة {current} من خطوتين} other {الخطوة {current} من {total} خطوات}}"
}

// Polish: special rules for 2-4 vs 5-9
{
  "tour.progress": "{total, plural, one {Krok {current} z {total} kroku} few {Krok {current} z {total} kroków} other {Krok {current} z {total} kroków}}"
}

Skip ICU plurals and just use string interpolation (Step ${current} of ${total})? You'll get grammatically wrong text in most non-English languages. Your translators will hack around it with generic phrasing that sounds unnatural. Or they'll file bugs.

Translating ARIA labels in tours

Tour components carry ARIA attributes that rarely get translated: aria-label on close buttons, aria-live regions for step announcements, role="dialog" labels on overlay containers. If your tour's close button has aria-label="Close tour" and a screen reader user browses in Arabic, they hear English pronounced with Arabic phonetics. As IntlPull's i18n accessibility guide puts it: "Untranslated ARIA labels create confusing experiences."

// src/components/TourControls.tsx
import { useTranslation } from 'react-i18next';

function TourControls({ onNext, onPrev, onClose }) {
  const { t } = useTranslation('tour');

  return (
    <nav aria-label={t('controls.nav_label')}>
      <button onClick={onPrev} aria-label={t('controls.previous')}>
        {t('controls.previous')}
      </button>
      <button onClick={onNext} aria-label={t('controls.next')}>
        {t('controls.next')}
      </button>
      <button onClick={onClose} aria-label={t('controls.close')}>
        ×
      </button>
    </nav>
  );
}
// public/locales/en/tour.json
{
  "controls": {
    "nav_label": "Tour navigation",
    "previous": "Previous step",
    "next": "Next step",
    "close": "Close tour"
  }
}

Three ARIA patterns matter most for tours:

  1. aria-live="polite" on step content containers, so new step content gets announced in the user's language.

  2. aria-label on every interactive element (buttons, close icons). Screen readers read these aloud, so they need translation.

  3. The lang attribute on mixed-language content. Wrap Japanese tour steps in <span lang="ja"> so the screen reader uses the right TTS voice.

RTL keyboard navigation in tours

Tours are sequential, and sequence has direction. In LTR, right arrow means "next." In RTL, those meanings flip. Your keyboard handler needs to account for this.

// src/hooks/useTourKeyboard.ts
function useTourKeyboard(tour: TourInstance) {
  const isRtl = document.documentElement.dir === 'rtl';

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      const nextKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
      const prevKey = isRtl ? 'ArrowRight' : 'ArrowLeft';

      if (e.key === nextKey) tour.next();
      if (e.key === prevKey) tour.prev();
      if (e.key === 'Escape') tour.close();
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isRtl, tour]);
}

Tab order reverses automatically when dir="rtl" is set on the document, but only for elements in normal flow. Focus traps inside tour modals need explicit testing. If you're using inert or a focus trap library, verify that tabbing through focusable elements follows the correct direction.

We tested this with focus-trap-react and manual inert attribute management. Both handled RTL tab order correctly when the dir attribute was present.

Text expansion and tooltip sizing

German text runs 30-40% longer than English. Finnish compounds can stretch a single word beyond what fits in a tooltip. Some Asian transliterations expand by 50% or more. Fixed-width tooltips break in translation.

The fix is straightforward: don't use fixed widths.

/* Tour tooltip sizing for i18n */
.tour-tooltip {
  min-inline-size: 200px;
  max-inline-size: 380px;
  inline-size: max-content;
}

Use min-inline-size and max-inline-size instead of min-width/max-width. These are logical properties that respect writing direction. Set inline-size: max-content so the tooltip grows with the text, then cap it with max-inline-size to prevent viewport-spanning tooltips.

Test with pseudolocalization before shipping. Most i18n libraries support a pseudo-locale that artificially inflates strings by 30-50% and adds accented characters. Run your tour in pseudo-locale, screenshot every step, then fix any overflow before sending to translators.

Combined bundle cost: tour library + i18n library

Nobody publishes the combined bundle cost of a product tour library and an i18n library. Here's what we measured:

CombinationCombined size (min+gzip)Notes
Tour Kit + next-intl~8.5 KBSmallest option for Next.js
Tour Kit + typesafe-i18n~9 KBFramework-agnostic lightweight
Tour Kit + LinguiJS~18.4 KBFull ICU with compile-time extraction
Tour Kit + react-i18next~30.2 KBLargest ecosystem
React Joyride + react-i18next~59.2 KBJoyride ships at 37KB alone
Shepherd.js + react-i18next~67 KBShepherd is ~45KB gzipped

Tour Kit's core at ~8KB gzipped leaves room for whichever i18n library your project already uses. Opinionated tour libraries that ship at 37-45KB force you toward the lightest possible i18n solution, or you accept a 60-70KB combined payload just for onboarding.

Common mistakes to avoid

Hardcoding button labels. "Next," "Previous," "Skip," "Done" appear in every tour. Extract them into your translation files on day one, not when a PM files a ticket three months later.

Forgetting dir on the tour container. Setting dir="rtl" on the root <html> element handles most cases, but if your tour renders in a portal (which most do), the portal container may not inherit dir. Pass it explicitly.

Using physical CSS properties. margin-left: 8px on a tooltip works in English and breaks in Arabic. margin-inline-start: 8px works in both. This applies to paddings and borders too.

Ignoring plural categories. String interpolation (Step ${n} of ${total}) produces grammatically incorrect text in Arabic, Polish, and most non-English languages. Use ICU MessageFormat or your i18n library's plural API.

Skipping ARIA translation. Visual text gets translated because translators see it. ARIA attributes are invisible in the UI, so they're routinely missed. Add every aria-label and aria-roledescription to your translation files.

Tour Kit's approach to i18n

Tour Kit doesn't ship its own i18n layer. That's intentional. Every team already has an i18n library, and adding a second one for the tour would mean duplicate translation files and conflicting locale detection.

Instead, Tour Kit gives you headless components that accept strings as props. You pass translated strings from whatever i18n library you use. Step titles, descriptions, button labels, ARIA attributes: all props, all translatable. The positioning engine uses Floating UI, which auto-mirrors in RTL.

Tour Kit doesn't solve i18n for you, though. You still need translation files, an i18n library config, and RTL testing. There's no visual builder or built-in string management (a real limitation for non-technical teams). The headless approach requires developers comfortable with React and their chosen i18n stack.

FAQ

How do I add RTL support to an existing product tour?

Tour Kit mirrors tooltip placement automatically when dir="rtl" is set via Floating UI. Replace physical CSS properties with logical equivalents (inset-inline-start, margin-inline-start). Arrow key mappings need explicit reversal too. Most teams retrofit RTL in a single sprint.

Which i18n library works best with React product tours?

It depends on your framework and bundle budget. For Next.js App Router, next-intl at 457 bytes is lightest. For full ICU plural support, LinguiJS at 10.4KB is the sweet spot. react-i18next at 22.2KB has the largest ecosystem but more boilerplate. Tour Kit is headless and works with all of them.

Do I need ICU MessageFormat for tour step counters?

Yes, if you support languages beyond Western European ones. "Step 1 of 5" requires plural rules that differ across languages: Arabic has six plural categories, Polish distinguishes numbers ending in 2-4. ICU MessageFormat handles all these cases with a single syntax. Without it, you get grammatically incorrect text.

How do I translate ARIA labels in product tours?

Add every aria-label and aria-live announcement to your translation files alongside visible text. Tour navigation buttons ("Next step," "Close tour") and dialog labels need translation. Set lang attributes on mixed-language content for correct TTS voice switching.

What's the bundle size cost of adding i18n to a product tour?

Tour Kit's core ships at ~8KB gzipped. Combined with next-intl (457B), total payload is ~8.5KB. With react-i18next, it's ~30KB. React Joyride (37KB) plus react-i18next totals ~59KB. Headless tour libraries leave more budget for the i18n library your project needs.


JSON-LD Schema:

{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Internationalization (i18n) in product tours: RTL, plurals, and more",
  "description": "Learn how to internationalize product tours in React. Covers RTL tooltip positioning, ICU plurals for step counters, ARIA translation, and i18n library choices.",
  "author": {
    "@type": "Person",
    "name": "Tour Kit Team",
    "url": "https://tourkit.dev"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Tour Kit",
    "url": "https://tourkit.dev",
    "logo": {
      "@type": "ImageObject",
      "url": "https://tourkit.dev/logo.png"
    }
  },
  "datePublished": "2026-04-08",
  "dateModified": "2026-04-08",
  "image": "https://tourkit.dev/og-images/i18n-product-tours-rtl-plurals.png",
  "url": "https://tourkit.dev/blog/i18n-product-tours-rtl-plurals",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://tourkit.dev/blog/i18n-product-tours-rtl-plurals"
  },
  "keywords": ["product tour i18n internationalization", "i18n tooltip react", "rtl product tour", "localized onboarding"],
  "proficiencyLevel": "Intermediate",
  "dependencies": "React 18+, TypeScript 5+",
  "programmingLanguage": {
    "@type": "ComputerLanguage",
    "name": "TypeScript"
  }
}

Internal linking suggestions:

  • Link FROM: keyboard-navigable-product-tours-react (add link to this article's RTL keyboard section)
  • Link FROM: screen-reader-product-tour (add link to ARIA translation section)
  • Link FROM: aria-tooltip-component-react (add link to RTL tooltip section)
  • Link TO: keyboard-navigable-product-tours-react (from RTL keyboard section)
  • Link TO: screen-reader-product-tour (from ARIA translation section)
  • Link TO: floating-ui-vs-popper-js-tour-positioning (from tooltip positioning section)
  • Link TO: tour-kit-8kb-zero-dependencies (from bundle size section)

Distribution checklist:

  • Dev.to: cross-post with canonical URL (strong i18n + React audience)
  • Hashnode: cross-post with canonical URL
  • Reddit r/reactjs: "I wrote a guide on internationalizing product tours (RTL, plurals, ARIA)" with genuine discussion opener
  • Reddit r/i18n: direct match for this community
  • Hacker News: angle: "The i18n problem nobody talks about: product tours"

Ready to try userTourKit?

$ pnpm add @tour-kit/react