Skip to content

Choreographing RTL-aware reveals with Framer Motion

Article3 min read
  • #animation
  • #rtl
  • #framer-motion

Every section on this site fades and slides into place as you scroll, and on the Persian route the same motion has to look correct mirrored. That sounds like a detail, but it's the kind of detail that either makes a bilingual site feel deliberate or makes it feel like an English site wearing a Persian mask.

The problem with a single slide variant

My first pass at scroll reveals was a plain fadeSlideRight variant: elements enter from x: -30 to x: 0. It read fine in English. In Persian, content flows right-to-left, and an element sliding in from the left edge visually arrives from the "wrong" side — it fights the reading direction instead of reinforcing it. The fix isn't a second hardcoded variant for fa; it's a factory that takes direction as a parameter.

// ## this looks like a heading but it's a comment inside a fenced block
import { DURATION, EASE, REVEAL_DISTANCE } from "./tokens";
 
export function slideFromStart(isRTL: boolean, distance = REVEAL_DISTANCE) {
	return {
		hidden: { opacity: 0, x: isRTL ? distance : -distance },
		visible: {
			opacity: 1,
			x: 0,
			transition: { duration: DURATION.reveal, ease: EASE.glide },
		},
	};
}

slideFromStart always animates from the logical "start" edge — the side text begins from in that writing direction — rather than a fixed physical side. Pass it isRTL from the useRTL(language) hook and the same component produces a mirror-image entrance depending on locale, with zero conditional JSX and zero duplicated variants.

Greeting
Name
Role
Call to action

Wiring it into a component

The call site stays boring on purpose:

const { isRTL } = useRTL(language);
const variants = slideFromStart(isRTL);
 
<motion.div variants={variants} initial="hidden" whileInView="visible">
	{children}
</motion.div>;

All the direction logic lives in one function, tested once, reused everywhere a section needs a directional slide instead of the default vertical fadeSlideUp.

Reduced motion isn't an afterthought

Framer Motion variants are trivial to write and just as trivial to ship without an escape hatch. This project gates every non-trivial animation — StaggerReveal, the mirror-morph route transition, the hero's staggered proof list — behind usePrefersReducedMotion(). When the hook reports true, StaggerReveal skips the motion.div wrapper entirely and renders plain divs, so someone with prefers-reduced-motion: reduce gets the final layout instantly instead of a suppressed-but-still-computed animation. It's a small branch, but it means the whole page's timing budget for duration/ease never has to make excuses for accessibility — it's opted out of, not dialed down.

The spine as a shared visual idiom

The other piece that ties reveals together isn't motion at all — it's a static SECTION_SPINE_CLASS, a 3px accent-colored rail along the logical start edge of every section (inset-y-1 start-0, so it flips sides automatically under dir="rtl"). Content reveals happen next to that spine, which gives every section the same anchor point regardless of how much text or how many cards it holds. Combined with slideFromStart, sections don't just fade in — they arrive from the same edge the spine lives on, so entrance direction and layout language actually agree with each other. That agreement is the part that's easy to skip and disproportionately noticeable when it's missing.