Week 24: Animations & Internationalization

Week 23 covered styling and accessibility as static concerns — how something looks and whether it's operable. This week adds two more dimensions that make an app feel finished: motion that guides attention without being gratuitous, and text, dates, and numbers that work correctly for users who aren't reading English in the US.

Module 14 of 17 Week 24 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build CSS-based transitions and keyframe animations, and know when reaching for a library beats plain CSS
  • Use Framer Motion for gesture and layout animations while respecting prefers-reduced-motion
  • Internationalize an app with react-i18next or next-intl, including locale-aware date/number/plural formatting

1. CSS Transitions & Keyframes in React

The simplest animation in React is still plain CSS, driven by a class toggled from state — no new dependency, no new mental model, just Week 2's useState controlling which class is applied.

Toast.tsx — mount/unmount driven by a CSS class
function Toast({ message, visible }: { message: string; visible: boolean }) {
  return (
    <div className={`toast ${visible ? 'toast--visible' : ''}`}>
      {message}
    </div>
  );
}
toast.css
.toast {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 200ms ease, transform 200ms ease;
}

.toast--visible {
  opacity: 1;
  transform: translateY(0);
}

@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.04); }
}

.toast--urgent {
  animation: pulse 1.2s ease-in-out infinite;
}

Notice both examples only ever animate opacity and transform — never width, height, top, or left. The browser can animate transform/opacity entirely on the compositor thread, without re-running layout or paint on every frame. Animating width or top forces the browser to recompute layout on every single frame, which is measurably slower and the single most common cause of janky CSS animations.

2. Framer Motion Fundamentals

Plain CSS handles simple, state-driven transitions well, but breaks down for anything needing to animate out before a component actually unmounts — React removes the DOM node immediately, before a CSS transition has a chance to play. Framer Motion (installed as framer-motion) solves this with motion components and AnimatePresence.

terminal
npm install framer-motion
Toast.tsx — a Framer Motion version, with a real exit animation
import { motion, AnimatePresence } from 'framer-motion';

function ToastContainer({ toast }: { toast: { id: string; message: string } | null }) {
  return (
    <AnimatePresence>
      {toast && (
        <motion.div
          key={toast.id}
          initial={{ opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -12 }}
          transition={{ duration: 0.2 }}
          className="toast"
        >
          {toast.message}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

initial is the state a motion.div animates from on mount, animate is its resting state, and exit is the state it animates to before actually being removed from the DOM. Without AnimatePresence wrapping it, React would unmount toast's DOM node the instant it becomes null, giving exit no chance to run — AnimatePresence is specifically what delays the real unmount until the exit animation finishes.

3. Layout Animations & Gestures

Adding a single layout prop to a motion component makes Framer Motion automatically animate any change to that element's position or size — reordering a list, an accordion expanding, a filter changing how many items render — using the same FLIP (First, Last, Invert, Play) technique you'd otherwise hand-roll.

ReorderableList.tsx — layout animations on filter/reorder
import { motion, AnimatePresence } from 'framer-motion';

function ReorderableList({ items }: { items: { id: string; label: string }[] }) {
  return (
    <ul>
      <AnimatePresence>
        {items.map((item) => (
          <motion.li
            key={item.id}
            layout                                  // animates position changes automatically
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            whileHover={{ scale: 1.02 }}
            whileTap={{ scale: 0.98 }}
          >
            {item.label}
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
}

When items reorders or shrinks, every remaining motion.li with layout smoothly animates to its new position instead of snapping there instantly — Framer Motion measures each element's position before and after the re-render and interpolates between them. whileHover and whileTap are declarative gesture props: state that only applies while the corresponding pointer interaction is active, no manual onMouseEnter/ onMouseLeave state tracking required.

4. Respecting prefers-reduced-motion

Some users — for vestibular disorders, motion sensitivity, or simple preference — set their OS to request reduced motion. This isn't a style preference to politely consider; large or fast motion can trigger real physical symptoms for some users, which is why it's treated as an accessibility requirement, on the same tier as Week 23's keyboard support.

AnimatedPanel.tsx — swapping a slide for an instant fade
import { motion, useReducedMotion } from 'framer-motion';

function AnimatedPanel({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: 40 }}
      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}
      transition={{ duration: shouldReduceMotion ? 0.01 : 0.3 }}
    >
      {children}
    </motion.div>
  );
}

useReducedMotion() reads the prefers-reduced-motion media query reactively and returns true when the user has requested it. Rather than disabling the animation outright (which can feel visually abrupt), the pattern above keeps a fade for continuity but drops the large positional slide — motion that communicates state change without the large-scale movement that's the actual problem for sensitive users.

The plain-CSS equivalent

Section 1's hand-written CSS should wrap its transition/animation declarations in a @media (prefers-reduced-motion: reduce) { .toast { transition: none; } } block — the same requirement applies whether you're using Framer Motion or plain CSS; useReducedMotion is simply the React-idiomatic way to read that same OS-level signal.

5. Internationalization with react-i18next

Internationalization (i18n) separates user-facing text from your components entirely, so the same UI can render in any language by swapping which translation file is active — no hardcoded English strings scattered across the codebase.

terminal
npm install react-i18next i18next
locales/en.json
{
  "greeting": "Hello, {{name}}!",
  "cartItems_one": "{{count}} item in your cart",
  "cartItems_other": "{{count}} items in your cart"
}
locales/es.json
{
  "greeting": "¡Hola, {{name}}!",
  "cartItems_one": "{{count}} artículo en tu carrito",
  "cartItems_other": "{{count}} artículos en tu carrito"
}
CartSummary.tsx
import { useTranslation } from 'react-i18next';

function CartSummary({ userName, itemCount }: { userName: string; itemCount: number }) {
  const { t } = useTranslation();

  return (
    <div>
      <p>{t('greeting', { name: userName })}</p>
      <p>{t('cartItems', { count: itemCount })}</p>
    </div>
  );
}

t('greeting', { name: userName }) interpolates userName into the {'{{name}}'} placeholder from the active locale's JSON file. cartItems_one/cartItems_other is i18next's pluralization convention — t('cartItems', { count }) automatically picks the right key based on count, which matters because pluralization rules genuinely differ by language (some languages have more than two plural forms), not something you can hand-roll with a single count === 1 ? singular : plural ternary and expect to generalize.

6. Locale Routing & Formatting

Translated strings are only half the problem — dates, currency, and numbers also follow locale-specific conventions (decimal separators, currency symbol placement, date order) that are easy to get subtly wrong by hand-formatting them yourself.

formatting.ts — don't hand-roll this
const price = 1234.5;
const date = new Date();

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price);
// "$1,234.50"

new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price);
// "1.234,50 €"

new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date);
// "August 5, 2026"

new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long' }).format(date);
// "2026年8月5日"

Intl.NumberFormat and Intl.DateTimeFormat are built into every JavaScript engine and encode the actual formatting rules for every supported locale — separator placement, symbol position, calendar conventions — correctly, which a hand-written `$${price.toFixed(2)}` simply can't do once a non-US locale is involved.

For a Next.js app (Weeks 18–19), next-intl extends this to full locale-prefixed routing — /en/products, /es/products — with middleware that detects a visitor's preferred locale and redirects accordingly, and a useTranslations hook mirroring react-i18next's useTranslation API for Server and Client Components alike.

Right-to-left languages (Arabic, Hebrew) need one more layer: setting dir="rtl" on the root element flips the reading direction, but a stylesheet hardcoded with margin-left/padding-right won't flip with it. Logical CSS properties — margin-inline-start instead of margin-left, padding-inline-end instead of padding-right — describe "start" and "end" relative to the current reading direction, so the same stylesheet works correctly in both LTR and RTL without a separate RTL override file.

dir="rtl" alone isn't enough

Flipping dir without also using logical properties leaves physical margin-left/margin-right rules pointing the wrong way — a sidebar that should now hug the right edge stays hugging the left. dir="rtl" and logical CSS properties solve the problem together; either alone leaves a half-flipped layout.

7. Hands-on Exercise

Hands-on

An animated, internationalized shopping list

Combine motion and i18n on the same small component, since real features need both together.

Requirements:

  1. Build a list of items with add/remove controls, animating additions and removals with motion.li and AnimatePresence per Sections 2–3, including a layout prop so remaining items reflow smoothly when one is removed.
  2. Use useReducedMotion() so the animation degrades to an instant or fade-only transition when prefers-reduced-motion is enabled — test it by toggling the OS setting (or DevTools' emulation) rather than assuming the code is correct.
  3. Set up react-i18next with en.json and es.json translation files, and translate every user-facing string in the component, including a pluralized "N items in your list" string using the _one/_other convention from Section 5.
  4. Add a "last updated" timestamp to the list, formatted with Intl.DateTimeFormat using the currently active locale — confirm it actually renders differently between the English and Spanish locales.
  5. Add a locale switcher and confirm every piece — translated strings, the plural count, and the formatted date — updates correctly when you switch locales.
Hint

Test the pluralization with a count of exactly 1 and a count of 0 or 2+ separately — it's an easy place to accidentally hardcode the English "singular vs. everything else" rule instead of actually relying on i18next's _one/_other key selection.

Part 2 — Add a right-to-left locale:

Every layout decision made assuming left-to-right text quietly becomes a bug the moment a real RTL locale shows up. Add one and find out where.

  1. Add a third locale — Arabic (ar.json) or Hebrew (he.json) — with its own translations, and set dir="rtl" on <html> whenever that locale is active (and dir="ltr" otherwise).
  2. Audit your CSS for any physical-direction properties (margin-left, padding-right, text-align: left) in the shopping list component and its controls, and replace them with logical equivalents (margin-inline-start, Tailwind's ms-*/me-*, or the rtl: variant) so the layout mirrors correctly instead of just flipping the text direction inside an unchanged layout.
  3. Switch to the RTL locale and visually confirm: the list itself, any icons implying direction (an arrow, a chevron), and the locale switcher itself all read correctly right-to-left, not just the translated text.
  4. Re-run your add/remove animations in the RTL locale — confirm a "slide in" animation that visually made sense in LTR still reads correctly (entering from the appropriate edge) rather than sliding in from what's now the wrong side.
Hint

Logical properties resolve relative to the current dir automatically — margin-inline-start means "left" in LTR and "right" in RTL, no conditional CSS needed. Physical properties like margin-left never adapt, which is exactly why they're the most common source of RTL layout bugs.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should an animation prefer changing transform and opacity over properties like width or top?

transform and opacity can be animated entirely on the compositor thread, without the browser recomputing layout or repainting on every frame. Animating width, top, or similar layout-affecting properties forces a layout recalculation each frame, which is the most common cause of a janky, dropped-frame CSS animation.

Q2

What does Framer Motion's useReducedMotion() do, and why does it matter for accessibility rather than just aesthetics?

It reactively reads the OS-level prefers-reduced-motion setting, letting a component swap out large or fast motion for something calmer. It's an accessibility requirement, not a style preference, because large-scale motion can trigger real physical symptoms — dizziness, nausea — for users with vestibular disorders or motion sensitivity, which is why the setting exists at the OS level at all.

Q3

Why does Intl.NumberFormat beat hand-formatting a currency string like `$${price.toFixed(2)}`?

A hand-written template string bakes in one locale's conventions (USD, dollar sign in front, period as decimal separator) and breaks silently for any other — German currency formatting, for example, uses a comma as the decimal separator and places the symbol after the number. Intl.NumberFormat encodes the correct formatting rules for every locale built into the JavaScript engine, so switching locales just works without separate formatting logic per language.

Q4

Why do dir="rtl" and logical CSS properties like margin-inline-start need to be used together for a correct right-to-left layout?

dir="rtl" flips the reading direction, but physical properties like margin-left stay pointing left regardless, leaving elements hugging the wrong edge. Logical properties describe spacing relative to "start" and "end" of the current reading direction rather than a fixed physical side, so the same stylesheet automatically produces a correctly mirrored layout once dir="rtl" is set — neither one alone is sufficient.