Hello Loader

A cycling greeting screen sequence. Uses Framer Motion's popLayout to smoothly crossfade text without layout shifts.

A mechanical rolling text ticker designed for initial app load screens or waiting states.

It passes an array of strings to framer-motion's <AnimatePresence mode="popLayout">. The popLayout mode is the secret here it instantly removes the old text from the document flow (preventing the container from jumping horizontally) while allowing the new text to slide in perfectly underneath it.

Toggle the speed and avatar states above to see how it scales.

Install dependencies

npm install framer-motion

Source code

HelloLoader.tsx
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";

export interface HelloLoaderProps {
  greetings?: string[];
  interval?: number;
  avatarSrc?: string;
  showAvatar?: boolean;
  fullScreen?: boolean;
  isAnimating?: boolean;
  className?: string;
}

const DEFAULT_GREETINGS = ["hello", "স্বাগতম", "hola", "नमस्ते", "bonjour"];

export default function HelloLoader({
  greetings = DEFAULT_GREETINGS,
  interval = 250,
  avatarSrc,
  showAvatar = true,
  fullScreen = false,
  isAnimating = true,
  className,
}: HelloLoaderProps) {
  const [index, setIndex] = useState(0);

  useEffect(() => {
    if (!isAnimating) return;

    const id = setInterval(() => {
      setIndex((prev) => (prev + 1) % greetings.length);
    }, interval);

    return () => clearInterval(id);
  }, [greetings.length, interval, isAnimating]);

  const current = greetings[isAnimating ? index : 0];

  return (
    <div
      className={cn(
        "flex items-center justify-center",
        fullScreen && "fixed inset-0 z-50 bg-background",
        className
      )}
    >
      <div className="flex items-center gap-4 sm:gap-5">
        {showAvatar && avatarSrc && (
          <motion.img
            initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            transition={{ type: "spring", duration: 0.4, bounce: 0 }}
            src={avatarSrc}
            alt="Avatar"
            className="size-12 shrink-0 rounded-2xl object-cover shadow-sm sm:size-14"
          />
        )}
        
        {/* Fixed min-width prevents layout shift when word length changes */}
        <div className="relative flex min-w-[140px] items-center justify-start sm:min-w-[180px]">
          {/* popLayout removes the exiting element from the document flow instantly, allowing the perfect overlap */}
          <AnimatePresence mode="popLayout">
            <motion.span
              key={current}
              initial={{ opacity: 0, y: 12, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, y: -12, filter: "blur(2px)" }}
              transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}
              className="absolute left-0 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
            >
              {current}
            </motion.span>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}