OpenSourceUIOpenSourceUIOpensource UI
GitHub—
  1. Home
  2. Components
  3. Loaders
  4. Text Loader
Back to LoadersComponents / Loaders

Text Loader

Free Loaders React component — TextLoader. MIT licensed, copy-paste ready for Next.js and Tailwind CSS.

Animated text loader with a spinning color orb and staggered letter pulses. Pass text, variant, and textColor to match your UI.

Preview

Searching

Setup

How to use

Free for personal and commercial use. No UI attribution required. Include the MIT copyright notice when copying component source into your project. Read the MIT license.

  1. 1

    Run in your terminal:

    $npm install clsx tailwind-merge lucide-react
  2. 2

    Copy lib/cn.ts below. Skip if you already have cn().

  3. 3

    Copy the code below and create components/loaders/text-loader.tsx in your project.

  4. 4

    Import and render:

    Example

    import { TextLoader } from "@/components/loaders/text-loader";

    <TextLoader text="Searching" variant="ocean" textColor="#ffffff" />

lib/cn.ts

1
2
3
4
5
6
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

components/loaders/text-loader.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"use client";
 
import { useEffect, useRef } from "react";
 
import { cn } from "@/lib/cn";
 
export type TextLoaderVariant = "default" | "sunset" | "ocean" | "neon";
 
export type TextLoaderProps = Readonly<{
text?: string;
variant?: TextLoaderVariant;
textColor?: string;
className?: string;
}>;
 
const colorSchemes = {
default: {
midBg: "#7c0911",
shadows: [
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #ff5f9f inset, 0 4px 4px 0 #0693ff inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #d60a47 inset, 0 4px 4px 0 #fbef19 inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #ff5f9f inset, 0 4px 4px 0 #28a9ff inset",
],
},
sunset: {
midBg: "#7c2d12",
shadows: [
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #fb923c inset, 0 4px 4px 0 #f97316 inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #ea580c inset, 0 4px 4px 0 #fbbf24 inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #fb923c inset, 0 4px 4px 0 #fdba74 inset",
],
},
ocean: {
midBg: "#0c4a6e",
shadows: [
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #22d3ee inset, 0 4px 4px 0 #0284c7 inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #06b6d4 inset, 0 4px 4px 0 #38bdf8 inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #22d3ee inset, 0 4px 4px 0 #7dd3fc inset",
],
},
neon: {
midBg: "#14532d",
shadows: [
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #4ade80 inset, 0 4px 4px 0 #22d3ee inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #22c55e inset, 0 4px 4px 0 #2dd4bf inset",
"0 1px 1px 0 #fff inset, 0 3px 5px 0 #4ade80 inset, 0 4px 4px 0 #38bdf8 inset",
],
},
} as const;
 
function TextLoaderOrb({
scheme,
}: Readonly<{
scheme: (typeof colorSchemes)[TextLoaderVariant];
}>) {
const orbRef = useRef<HTMLDivElement>(null);
 
useEffect(() => {
const node = orbRef.current;
if (!node) return;
 
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
node.style.transform = "rotate(270deg)";
node.style.backgroundColor = scheme.midBg;
node.style.boxShadow = scheme.shadows[1];
return;
}
 
const animation = node.animate(
[
{
transform: "rotate(90deg)",
backgroundColor: "transparent",
boxShadow: scheme.shadows[0],
},
{
transform: "rotate(270deg)",
backgroundColor: scheme.midBg,
boxShadow: scheme.shadows[1],
offset: 0.5,
},
{
transform: "rotate(450deg)",
backgroundColor: "transparent",
boxShadow: scheme.shadows[2],
},
],
{
duration: 1500,
iterations: Number.POSITIVE_INFINITY,
easing: "linear",
},
);
 
return () => animation.cancel();
}, [scheme]);
 
return (
<div
ref={orbRef}
aria-hidden="true"
className="z-0 size-5 shrink-0 rounded-full bg-transparent will-change-transform motion-reduce:transform-none"
style={{ transform: "rotate(90deg)" }}
/>
);
}
 
export function TextLoader({
text = "Searching",
variant = "default",
textColor = "#ffffff",
className,
}: TextLoaderProps) {
const letters = text.split("");
const scheme = colorSchemes[variant];
 
return (
<div
data-slot="text-loader"
data-variant={variant}
className={cn(
"relative flex items-center justify-center gap-2.5 select-none",
className,
)}
style={{ color: textColor }}
role="status"
aria-live="polite"
aria-label={text}
>
<TextLoaderOrb scheme={scheme} />
 
<div className="flex gap-px" aria-hidden="true">
{letters.map((letter, index) => (
<span
key={`${letter}-${index}`}
className="inline-block animate-pulse rounded-[50ch] border-none opacity-40 will-change-[opacity] motion-reduce:animate-none motion-reduce:opacity-100"
style={{
animationDuration: "1.8s",
animationDelay: `${index * 0.09}s`,
}}
>
{letter}
</span>
))}
</div>
</div>
);
}
PreviousNext

On this

page

Jump to a section on this page.

  • lib/cn.ts
  • components/loaders/text-loader.tsx
Platinum slotAvailable

Feature your product here.

Sticky card next to every component — highest-intent placement.

Claim this card