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.
function Toast({ message, visible }: { message: string; visible: boolean }) {
return (
<div className={`toast ${visible ? 'toast--visible' : ''}`}>
{message}
</div>
);
}
.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.
npm install framer-motion
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.
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.
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.
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.
npm install react-i18next i18next
{
"greeting": "Hello, {{name}}!",
"cartItems_one": "{{count}} item in your cart",
"cartItems_other": "{{count}} items in your cart"
}
{
"greeting": "¡Hola, {{name}}!",
"cartItems_one": "{{count}} artículo en tu carrito",
"cartItems_other": "{{count}} artículos en tu carrito"
}
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.
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
An animated, internationalized shopping list
Combine motion and i18n on the same small component, since real features need both together.
Requirements:
- Build a list of items with add/remove controls, animating additions and removals with
motion.liandAnimatePresenceper Sections 2–3, including alayoutprop so remaining items reflow smoothly when one is removed. - Use
useReducedMotion()so the animation degrades to an instant or fade-only transition whenprefers-reduced-motionis enabled — test it by toggling the OS setting (or DevTools' emulation) rather than assuming the code is correct. - Set up react-i18next with
en.jsonandes.jsontranslation files, and translate every user-facing string in the component, including a pluralized "N items in your list" string using the_one/_otherconvention from Section 5. - Add a "last updated" timestamp to the list, formatted with
Intl.DateTimeFormatusing the currently active locale — confirm it actually renders differently between the English and Spanish locales. - Add a locale switcher and confirm every piece — translated strings, the plural count, and the formatted date — updates correctly when you switch locales.
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.
- Add a third locale — Arabic (
ar.json) or Hebrew (he.json) — with its own translations, and setdir="rtl"on<html>whenever that locale is active (anddir="ltr"otherwise). - 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'sms-*/me-*, or thertl:variant) so the layout mirrors correctly instead of just flipping the text direction inside an unchanged layout. - 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.
- 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.
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?
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?
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)}`?
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?
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.