Web InnoventixFreeCode

Typing Loader

Original · free

typing indicator loader

byWeb InnoventixReact + Tailwind
loadtypingloaders
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/load-typing.json
load-typing.tsx
"use client";

import { useId, useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Variant = "bounce" | "wave" | "pulse" | "blink";
type Speed = "slow" | "normal" | "fast";

const KEYFRAME: Record<Variant, string> = {
  bounce: "ltyp-bounce",
  wave: "ltyp-wave",
  pulse: "ltyp-pulse",
  blink: "ltyp-blink",
};

const DURATION: Record<Speed, number> = {
  slow: 1.5,
  normal: 1.05,
  fast: 0.7,
};

const VARIANTS: { id: Variant; label: string; hint: string }[] = [
  { id: "bounce", label: "Bounce", hint: "Dots hop in sequence" },
  { id: "wave", label: "Wave", hint: "Lift and scale together" },
  { id: "pulse", label: "Pulse", hint: "Breathe in and out" },
  { id: "blink", label: "Blink", hint: "Sharp opacity flicker" },
];

const SPEEDS: { id: Speed; label: string }[] = [
  { id: "slow", label: "Slow" },
  { id: "normal", label: "Normal" },
  { id: "fast", label: "Fast" },
];

function TypingDots({
  variant,
  speed,
  reduce,
}: {
  variant: Variant;
  speed: Speed;
  reduce: boolean;
}) {
  const dur = DURATION[speed];
  return (
    <span className="flex items-center gap-1.5" aria-hidden="true">
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          className="ltyp-dot block h-2 w-2 rounded-full bg-slate-500 dark:bg-slate-400"
          style={{
            animationName: reduce ? "none" : KEYFRAME[variant],
            animationDuration: `${dur}s`,
            animationTimingFunction: "ease-in-out",
            animationIterationCount: "infinite",
            animationDelay: `${(i * dur * 0.16).toFixed(2)}s`,
            animationPlayState: reduce ? "paused" : "running",
            willChange: "transform, opacity",
          }}
        />
      ))}
    </span>
  );
}

export default function LoadTyping() {
  const reduce = useReducedMotion() ?? false;
  const groupId = useId();

  const [isTyping, setIsTyping] = useState<boolean>(true);
  const [variant, setVariant] = useState<Variant>("bounce");
  const [speed, setSpeed] = useState<Speed>("normal");

  const activeVariant = VARIANTS.find((v) => v.id === variant) ?? VARIANTS[0];

  const enter = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, y: 10, scale: 0.96 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, y: -8, scale: 0.96 },
      };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-24">
      <style>{`
        @keyframes ltyp-bounce {
          0%, 80%, 100% { transform: translateY(0); opacity: .5; }
          40% { transform: translateY(-6px); opacity: 1; }
        }
        @keyframes ltyp-wave {
          0%, 60%, 100% { transform: translateY(0) scale(1); opacity: .5; }
          30% { transform: translateY(-5px) scale(1.28); opacity: 1; }
        }
        @keyframes ltyp-pulse {
          0%, 100% { transform: scale(.65); opacity: .4; }
          50% { transform: scale(1.15); opacity: 1; }
        }
        @keyframes ltyp-blink {
          0%, 100% { opacity: .22; }
          50% { opacity: 1; }
        }
        @keyframes ltyp-caret {
          0%, 100% { opacity: 1; }
          50% { opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .ltyp-dot { animation: none !important; }
          .ltyp-caret { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500/70 ltyp-caret" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Live demo
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Typing indicator
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A chat loader that tells people a reply is on the way. Toggle it on
            to watch the animation, or off to deliver the message.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-[1.15fr_1fr] lg:gap-8">
          {/* Chat preview */}
          <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-center gap-3 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
              <span
                className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-semibold text-white"
                aria-hidden="true"
              >
                MO
              </span>
              <div className="min-w-0">
                <p className="truncate text-sm font-semibold">Maya Okonkwo</p>
                <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                  {isTyping ? "typing…" : "online"}
                </p>
              </div>
            </div>

            <div className="flex flex-col gap-3 px-5 py-6">
              {/* Their message */}
              <div className="flex justify-start">
                <div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-slate-100 px-4 py-2.5 text-sm leading-relaxed text-slate-800 dark:bg-slate-800 dark:text-slate-100">
                  Pushed the new onboarding flow to staging.
                </div>
              </div>

              {/* Your message */}
              <div className="flex justify-end">
                <div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-indigo-600 px-4 py-2.5 text-sm leading-relaxed text-white">
                  Nice — pulling it up now.
                </div>
              </div>

              {/* Typing indicator / delivered message swap */}
              <div className="flex min-h-[3rem] items-end justify-start">
                <AnimatePresence mode="wait" initial={false}>
                  {isTyping ? (
                    <motion.div
                      key="typing"
                      initial={enter.initial}
                      animate={enter.animate}
                      exit={enter.exit}
                      transition={{ duration: reduce ? 0.12 : 0.28 }}
                      className="inline-flex items-center rounded-2xl rounded-tl-sm bg-slate-100 px-4 py-3 dark:bg-slate-800"
                    >
                      <TypingDots
                        variant={variant}
                        speed={speed}
                        reduce={reduce}
                      />
                    </motion.div>
                  ) : (
                    <motion.div
                      key="delivered"
                      initial={enter.initial}
                      animate={enter.animate}
                      exit={enter.exit}
                      transition={{ duration: reduce ? 0.12 : 0.28 }}
                      className="max-w-[85%] rounded-2xl rounded-tl-sm bg-slate-100 px-4 py-2.5 text-sm leading-relaxed text-slate-800 dark:bg-slate-800 dark:text-slate-100"
                    >
                      Take your time — the staging link is in the thread.
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            </div>

            {/* Screen-reader live announcement */}
            <p className="sr-only" role="status" aria-live="polite">
              {isTyping
                ? "Maya Okonkwo is typing"
                : "Maya Okonkwo sent: Take your time — the staging link is in the thread."}
            </p>
          </div>

          {/* Controls */}
          <div className="flex flex-col gap-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-start justify-between gap-4">
              <div>
                <p className="text-sm font-semibold">Simulate reply</p>
                <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                  {isTyping ? "Indicator is animating" : "Message delivered"}
                </p>
              </div>
              <button
                type="button"
                role="switch"
                aria-checked={isTyping}
                aria-label="Show typing indicator"
                onClick={() => setIsTyping((v) => !v)}
                className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                  isTyping
                    ? "bg-indigo-600"
                    : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
                    isTyping ? "translate-x-6" : "translate-x-1"
                  }`}
                />
              </button>
            </div>

            {/* Variant radio group */}
            <fieldset>
              <legend className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Animation style
              </legend>
              <div
                role="radiogroup"
                aria-label="Animation style"
                className="grid grid-cols-2 gap-2"
              >
                {VARIANTS.map((v) => (
                  <label key={v.id} className="cursor-pointer">
                    <input
                      type="radio"
                      name={`${groupId}-variant`}
                      value={v.id}
                      checked={variant === v.id}
                      onChange={() => setVariant(v.id)}
                      className="peer sr-only"
                    />
                    <span className="flex flex-col gap-0.5 rounded-xl border border-slate-200 px-3 py-2.5 text-sm transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white hover:border-slate-300 dark:border-slate-700 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-300 dark:peer-focus-visible:ring-offset-slate-900 dark:hover:border-slate-600">
                      <span className="font-medium">{v.label}</span>
                      <span className="text-xs text-slate-500 dark:text-slate-400">
                        {v.hint}
                      </span>
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            {/* Speed radio group */}
            <fieldset>
              <legend className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Speed
              </legend>
              <div
                role="radiogroup"
                aria-label="Speed"
                className="inline-flex rounded-xl border border-slate-200 p-1 dark:border-slate-700"
              >
                {SPEEDS.map((s) => (
                  <label key={s.id} className="cursor-pointer">
                    <input
                      type="radio"
                      name={`${groupId}-speed`}
                      value={s.id}
                      checked={speed === s.id}
                      onChange={() => setSpeed(s.id)}
                      className="peer sr-only"
                    />
                    <span className="block rounded-lg px-4 py-1.5 text-sm font-medium text-slate-600 transition-colors peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:text-slate-300 dark:peer-checked:bg-white dark:peer-checked:text-slate-900 dark:peer-focus-visible:ring-offset-slate-900">
                      {s.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            {/* Standalone preview swatch */}
            <div className="mt-auto flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3 dark:bg-slate-800/60">
              <span className="text-xs text-slate-500 dark:text-slate-400">
                {activeVariant.label} · {speed}
              </span>
              <span className="inline-flex items-center rounded-full bg-white px-3 py-2 shadow-sm dark:bg-slate-900">
                <TypingDots variant={variant} speed={speed} reduce={reduce} />
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →