Skip to content
Docs
Focus Blur Resolve

Focus Blur Resolve

A premium focus pull from heavy blur to crisp text, then a soft blur-out exit.

Loading...

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/text/focus-blur-resolve.json

The CLI installs the preset, text-animator.tsx, and text-animator.css in one step.

Manual

Install the shared runtime

Every preset in the text category shares its WAAPI loop with the same text-animator runtime and co-located text-animator.css. Skip this step if you already added them for another text preset.

mkdir -p components/animata/text && touch components/animata/text/text-animator.tsx components/animata/text/text-animator.css

Paste the runtime

Paste into text-animator.css and text-animator.tsx:

@layer components {
  .text-animation-stage {
    container-type: inline-size;
    display: grid;
    height: 100%;
    min-height: 0;
    width: 100%;
    place-items: center;
    grid-template-areas: "title";
  }
 
  .text-animation-title {
    margin: 0;
    grid-area: title;
    pointer-events: none;
    transform-style: preserve-3d;
  }
 
  .text-animation-unit {
    display: inline-block;
    white-space: pre;
    backface-visibility: hidden;
    transform-origin: 50% 55%;
    will-change: transform, opacity, filter;
  }
 
  .text-animation-unit.line {
    display: block;
  }
 
  .text-animation-kinetic-line {
    position: relative;
    height: 72px;
    width: min(92%, 270px);
  }
 
  .text-animation-kinetic-stack {
    position: relative;
    height: 132px;
    width: min(92%, 270px);
  }
 
  .text-animation-kinetic-word {
    position: absolute;
    left: 50%;
    top: 50%;
    white-space: nowrap;
    backface-visibility: hidden;
    will-change: transform, opacity, filter;
  }
 
  .text-animation-fallback {
    opacity: 0.74;
  }
}
"use client";
 
import { useEffect, useMemo, useRef, useState } from "react";
import { TEXT_ANIMATOR_DEMO_CLASS } from "@/animata/text/text-animator-demo";
import { cn } from "@/lib/utils";
import "./text-animator-demo.css";
import "./text-animator.css";
 
export type TextAnimationTarget = "whole" | "per-character" | "per-word" | "per-line";
 
export type TextAnimationStaggerMode = "normal" | "center-out" | "edges-in" | "reverse";
 
export type TextAnimationCustomRenderer =
  | "kinetic-center-build"
  | "kinetic-top-build"
  | "shared-slide-opacity-stage";
 
export interface TextAnimationFrameSpec {
  opacity?: number;
  xPx?: number;
  yPx?: number;
  zPx?: number;
  scale?: number;
  rotateDeg?: number;
  rotateXDeg?: number;
  rotateYDeg?: number;
  blurPx?: number;
  letterSpacingEm?: number;
  color?: string;
}
 
export interface TextAnimationPhaseSpec {
  durationMs: number;
  staggerMs: number;
  easing: string;
  from: TextAnimationFrameSpec;
  to: TextAnimationFrameSpec;
}
 
export interface TextAnimationSwapSpec {
  mode?: string;
  overlapMs?: number;
  microDelayMs?: number;
}
 
export interface TextAnimationBuildSpec {
  firstWordDurationMs?: number;
  pushDurationMs?: number;
  exitDurationMs?: number;
  holdMs?: number;
  betweenPhrasesMs?: number;
  entryOffsetPx?: number;
  entryOffsetYPx?: number;
  wordGapPx?: number;
  lineGapPx?: number;
  firstWordYPx?: number;
  entryScale?: number;
  entryBlurPx?: number;
  reflowBlurPx?: number;
  exitYPx?: number;
  exitBlurPx?: number;
  easing?: string;
  exitEasing?: string;
  wordOpacityFrom?: number;
  wordOpacityTo?: number;
  wordOpacityDurationMs?: number;
}
 
export interface TextAnimationSpec {
  id?: string;
  target?: TextAnimationTarget;
  enter: TextAnimationPhaseSpec;
  exit: TextAnimationPhaseSpec;
  swap?: TextAnimationSwapSpec;
  customRenderer?: TextAnimationCustomRenderer;
  staggerMode?: TextAnimationStaggerMode;
  build?: TextAnimationBuildSpec;
}
 
export interface TextAnimatorProps {
  spec: TextAnimationSpec;
  samples?: string[];
  phrases?: string[][];
  className?: string;
  stageClassName?: string;
  /** Merged onto each animated `h3.text-animation-title` node the runtime creates. */
  titleClassName?: string;
  speed?: number;
  holdMs?: number;
  gapMs?: number;
  yTravel?: number;
}
 
const DEFAULT_RUNTIME = {
  tileSpeed: 0.72,
  tileHoldMs: 550,
  tileGapMs: 320,
  tileYTravel: 0.58,
};
 
type TileFrame = {
  color?: string;
  filter: string;
  letterSpacingEm: number | null;
  opacity: number;
  transform: string;
};
 
type LoopController = {
  animations: Set<Animation>;
  cancelled: boolean;
  stage: HTMLElement;
  timers: Set<number>;
  pendingResolvers: Set<() => void>;
  runtime: typeof DEFAULT_RUNTIME;
  titleClassName?: string;
};
 
type KineticStageFrame = { filter: string; opacity: number; transform: string };
 
type TextPart = { text: string; animate: boolean; block?: boolean };
 
function splitTextByTarget(text: string, target: TextAnimationTarget): TextPart[] {
  if (target === "whole") return [{ text, animate: true }];
  if (target === "per-word") {
    const parts: TextPart[] = [];
    for (const m of text.matchAll(/(\S+|\s+)/g)) {
      parts.push({ text: m[0], animate: /\S/.test(m[0]) });
    }
    return parts;
  }
  if (target === "per-line") {
    return text.split("\n").map((line) => ({ text: line, animate: true, block: true }));
  }
  return [...text].map((c) => ({ text: c, animate: true }));
}
 
function getStaggerRanks(count: number, mode: TextAnimationStaggerMode = "normal"): number[] {
  const order: number[] = [];
  if (mode === "center-out") {
    const center = (count - 1) / 2;
    for (let i = 0; i < count; i += 1) order.push(i);
    order.sort((a, b) => Math.abs(a - center) - Math.abs(b - center) || a - b);
  } else if (mode === "edges-in") {
    let l = 0;
    let r = count - 1;
    while (l <= r) {
      order.push(l);
      if (r !== l) order.push(r);
      l += 1;
      r -= 1;
    }
  } else if (mode === "reverse") {
    for (let i = count - 1; i >= 0; i -= 1) order.push(i);
  } else {
    for (let i = 0; i < count; i += 1) order.push(i);
  }
  const ranks = Array.from({ length: count }, () => 0);
  order.forEach((index, rank) => {
    ranks[index] = rank;
  });
  return ranks;
}
 
function toAnimationFrame(values: TextAnimationFrameSpec | undefined, yTravel: number): TileFrame {
  const f = values ?? {};
  const frame: TileFrame = {
    filter: `blur(${f.blurPx ?? 0}px)`,
    letterSpacingEm: f.letterSpacingEm ?? null,
    opacity: f.opacity ?? 1,
    transform: `translate3d(${f.xPx ?? 0}px, ${(f.yPx ?? 0) * yTravel}px, ${
      f.zPx ?? 0
    }px) rotateX(${f.rotateXDeg ?? 0}deg) rotateY(${f.rotateYDeg ?? 0}deg) rotate(${
      f.rotateDeg ?? 0
    }deg) scale(${f.scale ?? 1})`,
  };
  if (f.color) frame.color = f.color;
  return frame;
}
 
function materializeTileFrame(
  frame: TileFrame,
  unit: HTMLElement,
  index: number,
  count: number,
  target: TextAnimationTarget,
): Keyframe {
  const k: Keyframe = {
    filter: frame.filter,
    letterSpacing: "0em",
    marginLeft: "0em",
    marginRight: "0em",
    opacity: frame.opacity,
    transform: frame.transform,
  };
  if (frame.color) k.color = frame.color;
  if (frame.letterSpacingEm == null) return k;
  if (target === "per-character") {
    const text = unit.textContent ?? "";
    const isGlyph = /\S/.test(text);
    const halfGap = `${frame.letterSpacingEm / 2}em`;
    k.marginLeft = isGlyph && index > 0 ? halfGap : "0em";
    k.marginRight = isGlyph && index < count - 1 ? halfGap : "0em";
    return k;
  }
  k.letterSpacing = `${frame.letterSpacingEm}em`;
  return k;
}
 
function makeTitle(
  doc: Document,
  text: string,
  target: TextAnimationTarget,
  titleClassName?: string,
) {
  const title = doc.createElement("h3");
  title.className = cn("text-animation-title", titleClassName);
  const units: HTMLSpanElement[] = [];
  splitTextByTarget(text, target).forEach((part) => {
    const unit = doc.createElement("span");
    unit.className = `text-animation-unit${part.block ? " line" : ""}`;
    unit.textContent = part.text;
    title.appendChild(unit);
    if (part.animate) units.push(unit);
  });
  return { title, units };
}
 
function applyPhaseStart(
  units: HTMLElement[],
  phase: "enter" | "exit",
  spec: TextAnimationSpec,
  stage: HTMLElement,
  yTravel: number,
) {
  const baseFrame = toAnimationFrame(spec[phase].from, yTravel);
  units.forEach((unit, index) => {
    const kf = materializeTileFrame(
      baseFrame,
      unit,
      index,
      units.length,
      spec.target ?? "per-character",
    );
    Object.assign(unit.style, kf);
  });
  stage.dataset.animationPhase = phase;
}
 
function animatePhase(
  controller: LoopController,
  units: HTMLElement[],
  phase: "enter" | "exit",
  spec: TextAnimationSpec,
): number {
  const { tileSpeed, tileYTravel } = controller.runtime;
  const currentPhase = spec[phase];
  const fromFrame = toAnimationFrame(currentPhase.from, tileYTravel);
  const toFrame = toAnimationFrame(currentPhase.to, tileYTravel);
  const duration = Math.max(140, Math.round(currentPhase.durationMs * tileSpeed));
  const delayStep = Math.max(0, Math.round(currentPhase.staggerMs * tileSpeed));
  const ranks = getStaggerRanks(units.length, spec.staggerMode ?? "normal");
 
  units.forEach((unit, index) => {
    const fromK = materializeTileFrame(
      fromFrame,
      unit,
      index,
      units.length,
      spec.target ?? "per-character",
    );
    const toK = materializeTileFrame(
      toFrame,
      unit,
      index,
      units.length,
      spec.target ?? "per-character",
    );
    registerAnimation(
      controller,
      unit.animate([fromK, toK], {
        delay: ranks[index] * delayStep,
        duration,
        easing: currentPhase.easing,
        fill: "forwards",
      }),
    );
  });
 
  return duration + Math.max(0, units.length - 1) * delayStep;
}
 
function createLoop(
  stage: HTMLElement,
  runtime: typeof DEFAULT_RUNTIME,
  titleClassName?: string,
): LoopController {
  return {
    animations: new Set(),
    cancelled: false,
    stage,
    timers: new Set(),
    pendingResolvers: new Set(),
    runtime,
    titleClassName,
  };
}
 
function cleanupLoop(c: LoopController) {
  c.cancelled = true;
  c.timers.forEach((t) => {
    window.clearTimeout(t);
  });
  c.timers.clear();
  c.animations.forEach((a) => {
    a.cancel();
  });
  c.animations.clear();
  // Resolve any in-flight sleep() promises so awaiting loop functions can
  // observe the cancellation flag and unwind, releasing closures + DOM refs.
  c.pendingResolvers.forEach((resolve) => {
    resolve();
  });
  c.pendingResolvers.clear();
  clearStage(c.stage);
}
 
function registerAnimation(c: LoopController, a: Animation): Animation {
  c.animations.add(a);
  void a.finished.finally(() => c.animations.delete(a));
  return a;
}
 
function schedule(c: LoopController, cb: () => void, delay: number) {
  if (c.cancelled) return;
  const t = window.setTimeout(() => {
    c.timers.delete(t);
    if (!c.cancelled) cb();
  }, delay);
  c.timers.add(t);
}
 
function sleep(c: LoopController, delay: number): Promise<void> {
  if (c.cancelled || delay <= 0) return Promise.resolve();
  return new Promise((resolve) => {
    const wrapped = () => {
      c.pendingResolvers.delete(wrapped);
      resolve();
    };
    c.pendingResolvers.add(wrapped);
    schedule(c, wrapped, delay);
  });
}
 
function waitForAnimations(animations: Animation[]) {
  return Promise.all(animations.map((a) => a.finished.catch(() => undefined)));
}
 
function clearStage(stage: HTMLElement) {
  while (stage.firstChild) stage.removeChild(stage.firstChild);
}
 
function mix(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}
 
function setKineticPose(el: HTMLElement, frame: KineticStageFrame) {
  el.style.filter = frame.filter;
  el.style.opacity = `${frame.opacity}`;
  el.style.transform = frame.transform;
}
 
function buildKineticFrame(
  x: number,
  y: number,
  scale: number,
  blur: number,
  opacity: number,
): KineticStageFrame {
  return {
    filter: `blur(${blur}px)`,
    opacity,
    transform: `translate(-50%, -50%) translate3d(${x}px, ${y}px, 0) scale(${scale})`,
  };
}
 
function titleFrame(values: TextAnimationFrameSpec | undefined, yTravel: number): Keyframe {
  const f = values ?? {};
  return {
    filter: `blur(${f.blurPx ?? 0}px)`,
    opacity: f.opacity ?? 1,
    transform: `translate3d(${f.xPx ?? 0}px, ${(f.yPx ?? 0) * yTravel}px, 0) scale(${f.scale ?? 1})`,
  };
}
 
async function runGenericLoop(c: LoopController, spec: TextAnimationSpec, samples: string[]) {
  const { stage, runtime } = c;
  const target = spec.target ?? "per-character";
  const microDelay = spec.swap?.microDelayMs ?? 0;
  // For crossfade swaps the new sample's enter starts `overlapMs` before the
  // old sample's exit completes; the two titles share the stage's "title"
  // grid area during that window. Other modes wait for exit + microDelay
  // before the swap.
  const overlap = spec.swap?.mode === "crossfade" ? (spec.swap?.overlapMs ?? 0) : 0;
  let currentIndex = 0;
  let currentTitle: HTMLElement | null = null;
  let currentUnits: HTMLSpanElement[] = [];
 
  const enterSample = (text: string): number => {
    const { title, units } = makeTitle(stage.ownerDocument, text, target, c.titleClassName);
    applyPhaseStart(units, "enter", spec, stage, runtime.tileYTravel);
    clearStage(stage);
    stage.appendChild(title);
    currentTitle = title;
    currentUnits = units;
    return animatePhase(c, units, "enter", spec);
  };
 
  const firstEnter = enterSample(samples[currentIndex]);
  await sleep(c, firstEnter + runtime.tileHoldMs);
 
  while (!c.cancelled) {
    const oldTitle = currentTitle;
    const exitTotal = currentUnits.length ? animatePhase(c, currentUnits, "exit", spec) : 0;
 
    if (overlap > 0 && oldTitle) {
      // Crossfade: start the new enter while the old exit is still finishing.
      await sleep(c, Math.max(0, exitTotal - overlap));
      if (c.cancelled) break;
 
      currentIndex = (currentIndex + 1) % samples.length;
      const next = makeTitle(stage.ownerDocument, samples[currentIndex], target, c.titleClassName);
      applyPhaseStart(next.units, "enter", spec, stage, runtime.tileYTravel);
      // Mount alongside the still-exiting old title; both share grid-area "title".
      stage.appendChild(next.title);
      const nextEnter = animatePhase(c, next.units, "enter", spec);
 
      // Drop the old title once its exit finishes (which is `overlap` ms from now).
      schedule(c, () => oldTitle.remove(), overlap);
 
      currentTitle = next.title;
      currentUnits = next.units;
 
      await sleep(c, nextEnter + runtime.tileHoldMs);
    } else {
      // Non-overlapping: wait for exit + microDelay, swap, gap, then enter.
      await sleep(c, exitTotal + microDelay);
      if (c.cancelled) break;
 
      currentIndex = (currentIndex + 1) % samples.length;
      const next = makeTitle(stage.ownerDocument, samples[currentIndex], target, c.titleClassName);
      applyPhaseStart(next.units, "enter", spec, stage, runtime.tileYTravel);
      clearStage(stage);
      stage.appendChild(next.title);
      currentTitle = next.title;
      currentUnits = next.units;
 
      await sleep(c, runtime.tileGapMs);
      if (c.cancelled) break;
 
      const nextEnter = animatePhase(c, next.units, "enter", spec);
      await sleep(c, nextEnter + runtime.tileHoldMs);
    }
  }
}
 
async function runSharedSlideOpacityLoop(
  c: LoopController,
  spec: TextAnimationSpec,
  samples: string[],
) {
  const { stage, runtime } = c;
  const enter = spec.enter;
  const exit = spec.exit;
  const build = spec.build ?? {};
  const titleDuration = Math.max(180, Math.round((enter.durationMs || 460) * runtime.tileSpeed));
  const wordOpacityDuration = Math.max(
    90,
    Math.round((build.wordOpacityDurationMs ?? 170) * runtime.tileSpeed),
  );
  const wordStaggerMs = Math.max(0, Math.round((enter.staggerMs || 72) * runtime.tileSpeed));
  const exitDuration = Math.max(140, Math.round((exit.durationMs || 300) * runtime.tileSpeed));
  const holdMs = Math.max(380, runtime.tileHoldMs);
  const gapMs = Math.max(180, runtime.tileGapMs);
  const wordOpacityFrom = build.wordOpacityFrom ?? 0;
  const wordOpacityTo = build.wordOpacityTo ?? 1;
  let currentIndex = 0;
 
  const enterPhrase = async (text: string): Promise<HTMLHeadingElement> => {
    const { title, units } = makeTitle(
      stage.ownerDocument,
      text,
      spec.target ?? "per-word",
      c.titleClassName,
    );
    const fromFrame = titleFrame(enter.from, runtime.tileYTravel);
    const toFrame = titleFrame(enter.to, runtime.tileYTravel);
    Object.assign(title.style, fromFrame);
    title.style.willChange = "transform, opacity, filter";
    units.forEach((u) => {
      u.style.opacity = `${wordOpacityFrom}`;
      u.style.willChange = "opacity";
    });
    clearStage(stage);
    stage.appendChild(title);
    const animations = [
      registerAnimation(
        c,
        title.animate([fromFrame, toFrame], {
          duration: titleDuration,
          easing: enter.easing,
          fill: "forwards",
        }),
      ),
      ...units.map((u, i) =>
        registerAnimation(
          c,
          u.animate([{ opacity: wordOpacityFrom }, { opacity: wordOpacityTo }], {
            delay: i * wordStaggerMs,
            duration: wordOpacityDuration,
            easing: enter.easing,
            fill: "forwards",
          }),
        ),
      ),
    ];
    await waitForAnimations(animations);
    Object.assign(title.style, toFrame);
    units.forEach((u) => {
      u.style.opacity = `${wordOpacityTo}`;
    });
    return title;
  };
 
  const exitPhrase = async (title: HTMLHeadingElement | null) => {
    if (!title || c.cancelled) return;
    const fromFrame = titleFrame(exit.from, runtime.tileYTravel);
    const toFrame = titleFrame(exit.to, runtime.tileYTravel);
    Object.assign(title.style, fromFrame);
    await waitForAnimations([
      registerAnimation(
        c,
        title.animate([fromFrame, toFrame], {
          duration: exitDuration,
          easing: exit.easing,
          fill: "forwards",
        }),
      ),
    ]);
    clearStage(stage);
  };
 
  while (!c.cancelled) {
    const title = await enterPhrase(samples[currentIndex]);
    await sleep(c, holdMs);
    if (c.cancelled) break;
    await exitPhrase(title);
    await sleep(c, gapMs);
    currentIndex = (currentIndex + 1) % samples.length;
  }
}
 
async function runKineticCenterBuildLoop(
  c: LoopController,
  spec: TextAnimationSpec,
  phrases: string[][],
) {
  const { stage, runtime } = c;
  const build = spec.build ?? {};
  const line = stage.ownerDocument.createElement("div");
  const firstWordDuration = Math.max(
    180,
    Math.round((build.firstWordDurationMs ?? 360) * runtime.tileSpeed),
  );
  const pushDuration = Math.max(180, Math.round((build.pushDurationMs ?? 480) * runtime.tileSpeed));
  const exitDuration = Math.max(140, Math.round((build.exitDurationMs ?? 260) * runtime.tileSpeed));
  const holdMs = Math.max(380, Math.round((build.holdMs ?? 980) * runtime.tileSpeed));
  const betweenPhrasesMs = Math.max(
    120,
    Math.round((build.betweenPhrasesMs ?? 220) * runtime.tileSpeed),
  );
  const entryOffsetPx = build.entryOffsetPx ?? 96;
  const wordGapPx = build.wordGapPx ?? 16;
  const firstWordLiftPx = build.firstWordYPx ?? 6;
  const entryScale = build.entryScale ?? 0.992;
  const entryBlurPx = build.entryBlurPx ?? 3.5;
  const reflowBlurPx = build.reflowBlurPx ?? 0.8;
  const exitLiftPx = build.exitYPx ?? -6;
  const exitBlurPx = build.exitBlurPx ?? 2.5;
  const easing = build.easing ?? "cubic-bezier(0.2, 0.8, 0.2, 1)";
  const exitEasing = build.exitEasing ?? "cubic-bezier(0.4, 0, 0.2, 1)";
  let phraseIndex = 0;
  let words: HTMLSpanElement[] = [];
  let positions: number[] = [];
 
  clearStage(stage);
  line.className = "text-animation-kinetic-line";
  stage.appendChild(line);
 
  const computePositions = (widths: number[]) => {
    const total = widths.reduce((s, w) => s + w, 0) + wordGapPx * Math.max(0, widths.length - 1);
    let cursor = -total / 2;
    return widths.map((w) => {
      const pos = cursor + w / 2;
      cursor += w + wordGapPx;
      return pos;
    });
  };
 
  const buildPhrase = async (phraseWords: string[]) => {
    line.innerHTML = "";
    words = [];
    positions = [];
    for (const [index, text] of phraseWords.entries()) {
      if (c.cancelled) return;
      const word = stage.ownerDocument.createElement("span");
      word.className = "text-animation-kinetic-word";
      word.textContent = text;
      line.appendChild(word);
      const widths = Array.from(line.children, (n) => (n as HTMLElement).offsetWidth);
      const nextPositions = computePositions(widths);
      const animations: Animation[] = [];
      if (index === 0) {
        const start = buildKineticFrame(0, firstWordLiftPx, entryScale, entryBlurPx, 0);
        setKineticPose(word, start);
        animations.push(
          registerAnimation(
            c,
            word.animate(
              [
                start,
                {
                  ...buildKineticFrame(0, firstWordLiftPx * 0.35, 0.998, entryBlurPx * 0.45, 0.78),
                  offset: 0.58,
                },
                buildKineticFrame(0, 0, 1, 0, 1),
              ],
              { duration: firstWordDuration, easing, fill: "forwards" },
            ),
          ),
        );
      } else {
        words.forEach((currentWord, i) => {
          const cX = positions[i];
          const nX = nextPositions[i];
          animations.push(
            registerAnimation(
              c,
              currentWord.animate(
                [
                  buildKineticFrame(cX, 0, 1, 0, 1),
                  {
                    ...buildKineticFrame(mix(cX, nX, 0.58), 0, 1, reflowBlurPx, 1),
                    offset: 0.52,
                  },
                  buildKineticFrame(nX, 0, 1, 0, 1),
                ],
                { duration: pushDuration, easing, fill: "forwards" },
              ),
            ),
          );
        });
        const targetX = nextPositions[index];
        const startX = targetX + entryOffsetPx;
        setKineticPose(word, buildKineticFrame(startX, 0, entryScale, entryBlurPx, 0));
        animations.push(
          registerAnimation(
            c,
            word.animate(
              [
                buildKineticFrame(startX, 0, entryScale, entryBlurPx, 0),
                {
                  ...buildKineticFrame(
                    mix(startX, targetX, 0.72),
                    0,
                    0.998,
                    entryBlurPx * 0.38,
                    0.84,
                  ),
                  offset: 0.6,
                },
                buildKineticFrame(targetX, 0, 1, 0, 1),
              ],
              { duration: pushDuration, easing, fill: "forwards" },
            ),
          ),
        );
      }
      await waitForAnimations(animations);
      nextPositions.forEach((position, i) => {
        const current = i === words.length ? word : words[i];
        setKineticPose(current, buildKineticFrame(position, 0, 1, 0, 1));
      });
      if (!words.includes(word)) words.push(word);
      positions = nextPositions;
    }
  };
 
  const exitPhrase = async () => {
    if (!words.length || c.cancelled) return;
    const animations = words.map((word, i) => {
      const p = positions[i];
      return registerAnimation(
        c,
        word.animate(
          [
            buildKineticFrame(p, 0, 1, 0, 1),
            {
              ...buildKineticFrame(p, exitLiftPx * 0.45, 1, exitBlurPx * 0.55, 0.62),
              offset: 0.52,
            },
            buildKineticFrame(p, exitLiftPx, 1, exitBlurPx, 0),
          ],
          { duration: exitDuration, easing: exitEasing, fill: "forwards" },
        ),
      );
    });
    await waitForAnimations(animations);
    line.innerHTML = "";
    words = [];
    positions = [];
  };
 
  while (!c.cancelled) {
    await buildPhrase(phrases[phraseIndex] ?? phrases[0]);
    await sleep(c, holdMs);
    if (c.cancelled) break;
    await exitPhrase();
    await sleep(c, betweenPhrasesMs);
    phraseIndex = (phraseIndex + 1) % phrases.length;
  }
}
 
async function runKineticTopBuildLoop(
  c: LoopController,
  spec: TextAnimationSpec,
  phrases: string[][],
) {
  const { stage, runtime } = c;
  const build = spec.build ?? {};
  const line = stage.ownerDocument.createElement("div");
  const firstWordDuration = Math.max(
    180,
    Math.round((build.firstWordDurationMs ?? 360) * runtime.tileSpeed),
  );
  const pushDuration = Math.max(180, Math.round((build.pushDurationMs ?? 500) * runtime.tileSpeed));
  const exitDuration = Math.max(140, Math.round((build.exitDurationMs ?? 320) * runtime.tileSpeed));
  const holdMs = Math.max(380, Math.round((build.holdMs ?? 1100) * runtime.tileSpeed));
  const betweenPhrasesMs = Math.max(
    120,
    Math.round((build.betweenPhrasesMs ?? 220) * runtime.tileSpeed),
  );
  const entryOffsetYPx = build.entryOffsetYPx ?? -28;
  const lineGapPx = build.lineGapPx ?? build.wordGapPx ?? 12;
  const firstWordLiftPx = build.firstWordYPx ?? -14;
  const entryScale = build.entryScale ?? 0.992;
  const entryBlurPx = build.entryBlurPx ?? 2.4;
  const reflowBlurPx = build.reflowBlurPx ?? 0.7;
  const exitLiftPx = build.exitYPx ?? 10;
  const exitBlurPx = build.exitBlurPx ?? 1.2;
  const easing = build.easing ?? "cubic-bezier(0.2, 0.8, 0.2, 1)";
  const exitEasing = build.exitEasing ?? "cubic-bezier(0.4, 0, 0.2, 1)";
  let phraseIndex = 0;
  let words: HTMLSpanElement[] = [];
  let positions: number[] = [];
 
  clearStage(stage);
  line.className = "text-animation-kinetic-stack";
  stage.appendChild(line);
 
  const computePositions = (heights: number[]) => {
    const total = heights.reduce((s, h) => s + h, 0) + lineGapPx * Math.max(0, heights.length - 1);
    let cursor = -total / 2;
    return heights.map((h) => {
      const pos = cursor + h / 2;
      cursor += h + lineGapPx;
      return pos;
    });
  };
 
  const buildPhrase = async (phraseWords: string[]) => {
    line.innerHTML = "";
    words = [];
    positions = [];
    for (const [index, text] of phraseWords.entries()) {
      if (c.cancelled) return;
      const word = stage.ownerDocument.createElement("span");
      word.className = "text-animation-kinetic-word";
      word.textContent = text;
      line.appendChild(word);
      const heights = Array.from(line.children, (n) => (n as HTMLElement).offsetHeight);
      const nextPositions = computePositions(heights);
      const animations: Animation[] = [];
      if (index === 0) {
        const start = buildKineticFrame(0, firstWordLiftPx, entryScale, entryBlurPx, 0);
        setKineticPose(word, start);
        animations.push(
          registerAnimation(
            c,
            word.animate(
              [
                start,
                {
                  ...buildKineticFrame(0, firstWordLiftPx * 0.35, 0.998, entryBlurPx * 0.45, 0.78),
                  offset: 0.58,
                },
                buildKineticFrame(0, 0, 1, 0, 1),
              ],
              { duration: firstWordDuration, easing, fill: "forwards" },
            ),
          ),
        );
      } else {
        words.forEach((currentWord, i) => {
          const cY = positions[i];
          const nY = nextPositions[i];
          animations.push(
            registerAnimation(
              c,
              currentWord.animate(
                [
                  buildKineticFrame(0, cY, 1, 0, 1),
                  {
                    ...buildKineticFrame(0, mix(cY, nY, 0.58), 1, reflowBlurPx, 1),
                    offset: 0.52,
                  },
                  buildKineticFrame(0, nY, 1, 0, 1),
                ],
                { duration: pushDuration, easing, fill: "forwards" },
              ),
            ),
          );
        });
        const targetY = nextPositions[index];
        setKineticPose(
          word,
          buildKineticFrame(0, targetY + entryOffsetYPx, entryScale, entryBlurPx, 0),
        );
        animations.push(
          registerAnimation(
            c,
            word.animate(
              [
                buildKineticFrame(0, targetY + entryOffsetYPx, entryScale, entryBlurPx, 0),
                {
                  ...buildKineticFrame(
                    0,
                    mix(targetY + entryOffsetYPx, targetY, 0.72),
                    0.998,
                    entryBlurPx * 0.38,
                    0.84,
                  ),
                  offset: 0.6,
                },
                buildKineticFrame(0, targetY, 1, 0, 1),
              ],
              { duration: pushDuration, easing, fill: "forwards" },
            ),
          ),
        );
      }
      await waitForAnimations(animations);
      nextPositions.forEach((position, i) => {
        const current = i === words.length ? word : words[i];
        setKineticPose(current, buildKineticFrame(0, position, 1, 0, 1));
      });
      if (!words.includes(word)) words.push(word);
      positions = nextPositions;
    }
  };
 
  const exitPhrase = async () => {
    if (!words.length || c.cancelled) return;
    const animations = words.map((word, i) => {
      const p = positions[i];
      return registerAnimation(
        c,
        word.animate(
          [
            buildKineticFrame(0, p, 1, 0, 1),
            {
              ...buildKineticFrame(0, p + exitLiftPx * 0.45, 1, exitBlurPx * 0.55, 0.62),
              offset: 0.52,
            },
            buildKineticFrame(0, p + exitLiftPx, 1, exitBlurPx, 0),
          ],
          { duration: exitDuration, easing: exitEasing, fill: "forwards" },
        ),
      );
    });
    await waitForAnimations(animations);
    line.innerHTML = "";
    words = [];
    positions = [];
  };
 
  while (!c.cancelled) {
    await buildPhrase(phrases[phraseIndex] ?? phrases[0]);
    await sleep(c, holdMs);
    if (c.cancelled) break;
    await exitPhrase();
    await sleep(c, betweenPhrasesMs);
    phraseIndex = (phraseIndex + 1) % phrases.length;
  }
}
 
export default function TextAnimator({
  spec,
  samples,
  phrases,
  className,
  stageClassName,
  titleClassName,
  speed,
  holdMs,
  gapMs,
  yTravel,
}: TextAnimatorProps) {
  const stageRef = useRef<HTMLDivElement | null>(null);
  const [failed, setFailed] = useState(false);
 
  // Track samples/phrases by their *content* rather than array reference so
  // a parent that hands us a fresh-but-identical array on every render
  // doesn't tear down and restart the animation. / are sentinels
  // unlikely to appear in user-supplied copy.
  const samplesKey = useMemo(() => samples?.join("") ?? "", [samples]);
  const phrasesKey = useMemo(() => phrases?.map((p) => p.join("")).join("") ?? "", [phrases]);
 
  // Refs let the effect read the latest samples/phrases without listing them
  // as deps (we drive re-runs via the content keys above).
  const samplesRef = useRef(samples);
  const phrasesRef = useRef(phrases);
  samplesRef.current = samples;
  phrasesRef.current = phrases;
 
  const effectiveSamples = samples?.length ? samples : ["Animation"];
 
  // biome-ignore lint/correctness/useExhaustiveDependencies: samplesKey/phrasesKey track content; samples/phrases are read live from refs.
  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    setFailed(false);
    if (spec.id) stage.dataset.animationId = spec.id;
 
    const runtime = {
      tileSpeed: speed ?? DEFAULT_RUNTIME.tileSpeed,
      tileHoldMs: holdMs ?? DEFAULT_RUNTIME.tileHoldMs,
      tileGapMs: gapMs ?? DEFAULT_RUNTIME.tileGapMs,
      tileYTravel: yTravel ?? DEFAULT_RUNTIME.tileYTravel,
    };
 
    const controller = createLoop(stage, runtime, titleClassName);
    const renderer = spec.customRenderer;
    const liveSamples = samplesRef.current;
    const livePhrases = phrasesRef.current;
    const effectiveSamples = liveSamples?.length ? liveSamples : ["Animation"];
    const effectivePhrases = livePhrases?.length ? livePhrases : [["Build", "the", "line"]];
 
    const launch = () => {
      let loop: Promise<void>;
      if (renderer === "kinetic-center-build") {
        loop = runKineticCenterBuildLoop(controller, spec, effectivePhrases);
      } else if (renderer === "kinetic-top-build") {
        loop = runKineticTopBuildLoop(controller, spec, effectivePhrases);
      } else if (renderer === "shared-slide-opacity-stage") {
        loop = runSharedSlideOpacityLoop(controller, spec, effectiveSamples);
      } else {
        loop = runGenericLoop(controller, spec, effectiveSamples);
      }
      void loop.catch((error) => {
        if (!controller.cancelled) {
          console.error(`Failed to run text animation "${spec.id ?? "unknown"}"`, error);
          setFailed(true);
          cleanupLoop(controller);
        }
      });
    };
 
    schedule(controller, launch, Math.random() * 400);
 
    return () => {
      cleanupLoop(controller);
    };
  }, [spec, samplesKey, phrasesKey, speed, holdMs, gapMs, yTravel, titleClassName]);
 
  const demoMode = className != null && String(className).includes(TEXT_ANIMATOR_DEMO_CLASS);
 
  return (
    <div
      data-text-animator-demo={demoMode ? "" : undefined}
      className={cn("relative flex w-full items-center justify-center overflow-hidden", className)}
    >
      <div ref={stageRef} className={cn("text-animation-stage absolute inset-0", stageClassName)}>
        {failed && effectiveSamples[0] ? (
          <h3 className={cn("text-animation-title text-animation-fallback", titleClassName)}>
            {effectiveSamples[0]}
          </h3>
        ) : null}
      </div>
    </div>
  );
}

Run the following command

It will create a new file called focus-blur-resolve.tsx inside the components/animata/text directory.

mkdir -p components/animata/text && touch components/animata/text/focus-blur-resolve.tsx

Paste the code

Open the newly created file and paste the following code:

"use client";
 
import { useMemo } from "react";
 
import TextAnimator, {
  type TextAnimationPhaseSpec,
  type TextAnimationSpec,
} from "@/animata/text/text-animator";
 
const BASE_SPEC: TextAnimationSpec = {
  id: "focus-blur-resolve",
  target: "whole",
  enter: {
    durationMs: 760,
    staggerMs: 0,
    easing: "cubic-bezier(0.22, 1, 0.36, 1)",
    from: {
      opacity: 0,
      yPx: 14,
      blurPx: 14,
      scale: 1.01,
    },
    to: {
      opacity: 1,
      yPx: 0,
      blurPx: 0,
      scale: 1,
    },
  },
  exit: {
    durationMs: 520,
    staggerMs: 0,
    easing: "cubic-bezier(0.64, 0, 0.78, 0)",
    from: {
      opacity: 1,
      yPx: 0,
      blurPx: 0,
      scale: 1,
    },
    to: {
      opacity: 0,
      yPx: -10,
      blurPx: 10,
      scale: 1,
    },
  },
  swap: {
    mode: "sequential",
    microDelayMs: 35,
  },
};
 
const DEFAULT_SAMPLES: readonly string[] = [
  "Pulls into focus.",
  "A camera-style resolve.",
  "Then a quiet defocus.",
];
 
export interface FocusBlurResolveProps {
  /** Text(s) to animate. String = single, array = cycle through. */
  text?: string | string[];
  /** Override enter phase (merged with default). */
  enter?: Partial<TextAnimationPhaseSpec>;
  /** Override exit phase (merged with default). */
  exit?: Partial<TextAnimationPhaseSpec>;
  /** Global speed multiplier. Defaults to the runtime default (0.72) when omitted. */
  speed?: number;
  /** Hold time between enter and exit in ms. Defaults to the runtime default (550) when omitted. */
  holdMs?: number;
  /** Gap between cycles in ms. Defaults to the runtime default (320) when omitted. */
  gapMs?: number;
  /** Y-travel multiplier for all transforms. Defaults to the runtime default (0.58) when omitted. */
  yTravel?: number;
  /** Outer wrapper — overrides default `aspect-video` centering when embedding in layouts. */
  className?: string;
  /** Passed to the inner `.text-animation-stage` grid. */
  stageClassName?: string;
  /** Merged onto the animated title node — use `text-xl!` to override preset sizing. */
  titleClassName?: string;
}
 
export default function FocusBlurResolve({
  text,
  enter,
  exit,
  speed,
  holdMs,
  gapMs,
  yTravel,
  className,
  stageClassName,
  titleClassName,
}: FocusBlurResolveProps = {}) {
  const spec = useMemo<TextAnimationSpec>(
    () => ({
      ...BASE_SPEC,
      enter: {
        ...BASE_SPEC.enter,
        ...enter,
        from: { ...BASE_SPEC.enter.from, ...enter?.from },
        to: { ...BASE_SPEC.enter.to, ...enter?.to },
      },
      exit: {
        ...BASE_SPEC.exit,
        ...exit,
        from: { ...BASE_SPEC.exit.from, ...exit?.from },
        to: { ...BASE_SPEC.exit.to, ...exit?.to },
      },
    }),
    [enter, exit],
  );
  const samples = useMemo(() => {
    if (text == null) return [...DEFAULT_SAMPLES];
    return Array.isArray(text) ? text : [text];
  }, [text]);
  return (
    <TextAnimator
      spec={spec}
      samples={samples}
      speed={speed}
      holdMs={holdMs}
      gapMs={gapMs}
      yTravel={yTravel}
      className={className}
      stageClassName={stageClassName}
      titleClassName={titleClassName}
    />
  );
}

Credits

Based on pixel-point/animate-text