Centering Tarot Card Orbits Without Breaking Hydration
I recently wrapped the about page around two animated tarot cards (src/app/[locale]/about/page.tsx). Each card shows a mystical back until the “Professional Journey” section flips them to reveal work experience. The shot looked great in mockups, but the first live run exposed two show-stoppers:
- The lunar and solar orbital rings drifted away from their moons/suns.
- The console screamed about React hydration mismatches.
This post is a short log of how I tracked both issues down and restored the illusion.
Symptom: off–center orbit
The new card backs live in src/components/ui/tarot-card.tsx. Each back renders a rotating SVG ring and a static celestial body. On screen, the ring sat down-right of the moon, leaving a huge empty arc on the opposite side. The screenshot looked like the orbit was chasing the moon instead of wrapping around it.
That usually means the rotation origin differs between the animated element and its visual anchor. In this case, the motion.div rotating the ring used Tailwind’s -translate-x-1/2 -translate-y-1/2 to center itself, while the moon SVG was wrapped in its own translated container. When both transforms combine with an animation, there’s no guarantee the local matrices compose the same way frame-to-frame, especially once the ring’s parent inherits additional layout shifts from scroll-triggered positioning.
Symptom: hydration error
While hunting the layout bug, I also saw this familiar message:
Hydration failed because the server rendered HTML didn't match the client.
Looking closely at the stack trace pointed back into the same component. The culprit lines showed numbers like 29.689110867544652 on the server and 29.689110867544656 on the client—the classic floating-point precision mismatch. Those floats came from the trig calculations driving the solar rays:
const angle = (idx * 360) / 12;
const rad = (angle * Math.PI) / 180;
const x1 = 60 + Math.cos(rad) * 35;
const y1 = 60 + Math.sin(rad) * 35;Different runtimes produced slightly different stringified numbers, so React bailed out and re-rendered the tree on the client.
Fixing the misalignment
I split the responsibilities:
-
Dedicated centering wrapper – Instead of rotating the translated element directly, I nested the animation inside an absolutely centered div:
<div className="relative h-40 w-40"> <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <motion.div className="flex h-32 w-32 items-center justify-center" style={{ transformOrigin: "50% 50%" }} animate={{ rotate: 360 }} transition={{ duration: 28, repeat: Infinity, ease: "linear" }} > {/* ring svg */} </motion.div> </div> </div>The moon/sun sit in an identical wrapper. Both now share the same absolute origin, so the ring can rotate in place without drifting.
-
Shared container size –
h-40 w-40for the lunar card andh-44 w-44for the solar variant guarantee enough padding for the ring while preserving perfect center alignment. -
Explicit transform origin – Setting
transformOrigin: "50% 50%"on themotion.divkeeps Framer Motion from inheriting unexpected values from the nested translations.
After these changes, the orbit finally hugged the moon regardless of scroll position.
Fixing the hydration mismatch
For the solar rays I rounded every coordinate before handing it to JSX:
const formatCoord = (value: number) => Number(value.toFixed(4));
const x1 = formatCoord(60 + Math.cos(rad) * 35);
const y1 = formatCoord(60 + Math.sin(rad) * 35);Four decimal places are more than enough for an SVG line, yet stable enough to match on both the server and the client. The hydration warning disappeared immediately.
Validation
To make sure nothing else broke:
- Hard refreshed the about page and scrolled through each section. The cards now stay centered from hero to journey, and the flip still triggers at the
journeySectionRefbreakpoint. - Checked the console for hydration warnings—none remain.
- Spot-tested mobile sizing; the wrappers still scale down gracefully.
Lessons learned
- Separate layout from animation. Let transforms own positioning, not the element you animate. Wrapping the
motion.divin a dedicated centering div made the entire fix trivial. - Rounding prevents hydration drift. If you generate floats in client components rendered on the server, format them to deterministic strings.
- Verify shared origins. When multiple elements rely on the same visual center, anchor them all to a common container instead of chaining translations.
Thanks to a few targeted tweaks, the tarot cards now read as intended—fluent, mystical, and perfectly centered. If you’re shipping complex scroll-driven motion in Next.js, keep an eye on both transform origins and deterministic server output. They’re easy to overlook and just as easy to tame once you understand the interplay.