All patterns

Translate Crossfade

Copy hands over to its translation while the block eases to the new text's height.

aicalmpremiuminteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

291 lines · react + motion only
import { useLayoutEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Translate Crossfade
 *
 * Switching a block of copy to another language. The old wording fades
 * out, the new fades in slightly behind it, and the block's height eases
 * to the length of the new text so the page settles once instead of
 * snapping.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so it reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `versions`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TranslationVersion = {
  /** Short code shown on the switch. */
  code: string;
  /** Full language name, used for the accessible label. */
  name: string;
  /** The copy in this language. */
  text: string;
  /** BCP 47 tag applied to the rendered text. */
  lang: string;
};

export type TranslateCrossfadeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Languages offered, source first. */
  versions?: TranslationVersion[];
  /** Small caption above the switch. */
  title?: string;
  /** Accent for the active language. */
  accent?: string;
  /** Fires with the code of the language chosen. */
  onLanguageChange?: (code: string) => void;
};

type VariantConfig = {
  /** px the incoming text rises or drops through. */
  travel: number;
  /** Seconds the outgoing text takes to clear. */
  outSeconds: number;
  /** Seconds the incoming text takes to resolve. */
  inSeconds: number;
  /** Seconds the block takes to reach the new height. */
  resizeSeconds: number;
};

// Quality rule: the copy is text, so it fades and travels a few pixels
// and never changes size — a paragraph that scales during a language
// switch is unreadable for the whole transition. Height is eased, not
// sprung: an overshooting block would push the rest of the page down and
// pull it back.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a straight swap. For a page where several blocks switch at
  // once.
  subtle: { travel: 0, outSeconds: 0.1, inSeconds: 0.14, resizeSeconds: 0.2 },
  // A clear handover with the height following. The all-purpose setting.
  default: { travel: 4, outSeconds: 0.14, inSeconds: 0.2, resizeSeconds: 0.3 },
  // Slower and more deliberate, for a single showcased passage.
  playful: { travel: 8, outSeconds: 0.18, inSeconds: 0.28, resizeSeconds: 0.42 },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_VERSIONS: TranslationVersion[] = [
  {
    code: "EN",
    name: "English",
    lang: "en",
    text: "Orders confirmed before 14:00 leave the warehouse the same working day. Anything crossing a border should be quoted with an extra day on top of the carrier estimate.",
  },
  {
    code: "ES",
    name: "Spanish",
    lang: "es",
    text: "Los pedidos confirmados antes de las 14:00 salen del almacén el mismo día laborable. Para los envíos internacionales, añade un día a la estimación del transportista.",
  },
  {
    code: "JA",
    name: "Japanese",
    lang: "ja",
    text: "14時までに確定した注文は当日出荷されます。国境を越える配送には、配送業者の見積もりに一日を加えてご案内ください。",
  },
];

export default function TranslateCrossfade({
  variant = "default",
  versions = DEFAULT_VERSIONS,
  title = "Shipping policy",
  accent = "#5B5BD6",
  onLanguageChange,
}: TranslateCrossfadeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [index, setIndex] = useState(0);
  const [previous, setPrevious] = useState(0);
  const [direction, setDirection] = useState(1);
  const [height, setHeight] = useState<number | null>(null);
  const [ring, setRing] = useState<string | null>(null);
  const activeRef = useRef<HTMLParagraphElement | null>(null);

  // The active paragraph stays in the flow, so its natural height is
  // always readable even while the container is held at the old one.
  // Measuring before paint means the swap never shows a wrong height.
  useLayoutEffect(() => {
    setHeight(activeRef.current?.offsetHeight ?? null);
  }, [index, variant, versions]);

  const choose = (next: number) => {
    if (next === index) return;
    setDirection(next > index ? 1 : -1);
    setPrevious(index);
    setIndex(next);
    onLanguageChange?.(versions[next]?.code ?? "");
  };

  return (
    <div
      style={{
        width: 320,
        padding: 16,
        borderRadius: 14,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
        color: "inherit",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          marginBottom: 12,
        }}
      >
        <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
          <circle cx="8" cy="8" r="5.9" stroke="currentColor" strokeWidth="1.3" opacity="0.5" />
          <path
            d="M2.3 8h11.4M8 2.1c1.6 1.7 2.4 3.7 2.4 5.9S9.6 12.2 8 13.9C6.4 12.2 5.6 10.2 5.6 8S6.4 3.8 8 2.1Z"
            stroke="currentColor"
            strokeWidth="1.3"
            opacity="0.5"
          />
        </svg>
        <span style={{ fontSize: 12, fontWeight: 650, opacity: 0.7 }}>{title}</span>

        <div
          role="group"
          aria-label="Language"
          style={{
            marginLeft: "auto",
            display: "inline-flex",
            gap: 3,
            padding: 2,
            borderRadius: 8,
            background: tone(7),
            border: `1px solid ${tone(11)}`,
          }}
        >
          {versions.map((version, versionIndex) => {
            const active = versionIndex === index;
            return (
              <button
                key={version.code}
                type="button"
                onClick={() => choose(versionIndex)}
                aria-pressed={active}
                aria-label={version.name}
                onFocus={(event) =>
                  setRing(
                    event.currentTarget.matches(":focus-visible")
                      ? version.code
                      : null
                  )
                }
                onBlur={() => setRing(null)}
                style={{
                  padding: "2.5px 8px",
                  fontFamily: "inherit",
                  fontSize: 10.5,
                  fontWeight: 700,
                  letterSpacing: 0.4,
                  color: active ? "#fff" : "inherit",
                  opacity: active ? 1 : 0.6,
                  background: active ? accent : "transparent",
                  border: "none",
                  borderRadius: 6,
                  cursor: "pointer",
                  boxShadow: ring === version.code ? `0 0 0 3px ${tone(22)}` : "none",
                  outline: "none",
                  transition: "background 160ms ease-out, opacity 160ms ease-out",
                }}
              >
                {version.code}
              </button>
            );
          })}
        </div>
      </div>

      {/*
        Height is the one non-transform property animated here, and it is
        animated because the motion genuinely is a size change: the same
        sentence is a different length in every language.
      */}
      <motion.div
        initial={false}
        // Motion owns the height outright: `auto` covers the single
        // render before the paragraph is measured, and `initial={false}`
        // applies that first state instead of animating to it.
        animate={{ height: height ?? "auto" }}
        transition={{
          duration: reduceMotion ? 0 : cfg.resizeSeconds,
          ease: [0.22, 0.61, 0.36, 1],
        }}
        style={{ position: "relative", overflow: "hidden" }}
      >
        {versions.map((version, versionIndex) => {
          const active = versionIndex === index;
          const paragraph = (
            <p
              ref={active ? activeRef : undefined}
              lang={version.lang}
              style={{
                margin: 0,
                fontSize: 12.5,
                lineHeight: 1.6,
                opacity: 0.85,
              }}
            >
              {version.text}
            </p>
          );
          return (
            <motion.div
              key={version.code}
              aria-hidden={!active}
              initial={false}
              // Both blocks travel the same way: the incoming one rises
              // from where the outgoing one is heading, so the swap reads
              // as one movement rather than as two panels parting.
              animate={{
                opacity: active ? 1 : 0,
                y:
                  active || reduceMotion
                    ? 0
                    : versionIndex === previous
                      ? -direction * cfg.travel
                      : direction * cfg.travel,
              }}
              transition={{
                duration: reduceMotion
                  ? 0
                  : active
                    ? cfg.inSeconds
                    : cfg.outSeconds,
                delay: reduceMotion || !active ? 0 : cfg.outSeconds * 0.5,
                ease: active ? "easeOut" : "easeIn",
              }}
              style={
                active
                  ? { position: "relative" }
                  : {
                      position: "absolute",
                      left: 0,
                      right: 0,
                      top: 0,
                      pointerEvents: "none",
                    }
              }
            >
              {paragraph}
            </motion.div>
          );
        })}
      </motion.div>
    </div>
  );
}

About this pattern

Switching a passage to another language without the page jolting. The outgoing wording clears, the incoming rises into the space it left, and the block's height eases to whatever the new text actually needs — the same sentence is a different length in every language, so the size change is real and worth animating. The active paragraph stays in the document flow so its natural height can always be measured before paint, which means the swap never shows a wrong height for a frame. Text fades and travels a few pixels; it never scales.

Translate a passage in placeLanguage switch on a policy blockRewrite or rephrase resultLocalized product copy preview

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    The output pane resizes to the translated length instead of snapping.

Related patterns