Text Effects
TypeNova
Types the text one letter at a time. Each new letter lands as a bright flash that cools down, behind a glowing caret. Starts when scrolled into view.
Download
- React
- Tailwind CSS v4
- motion
components/ui/type-nova.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type TypeNovaProps = {
text: string;
className?: string;
/** Milliseconds per letter. */
speed?: number;
/** Colour of the flash each new letter lands with. */
glow?: string;
/** Type only the first time it scrolls into view. */
once?: boolean;
};
/* TypeNova: types the text one letter at a time; each new letter lands as a bright flash that cools down. */
export function TypeNova({ text, className = "", speed = 55, glow = "#a78bfa", once = true }: TypeNovaProps) {
const reduce = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once, amount: 0.6 });
const [count, setCount] = useState(0);
useEffect(() => {
if (!inView || reduce) return;
let n = 0;
let timer = 0;
const tick = () => {
setCount(n);
if (n++ < text.length) timer = window.setTimeout(tick, speed);
};
timer = window.setTimeout(tick, 0);
return () => window.clearTimeout(timer);
}, [inView, reduce, text, speed]);
const shown = reduce ? text.length : count;
const letters = [...text];
return (
<span ref={ref} className={className}>
<span className="sr-only">{text}</span>
<span aria-hidden>
{letters.slice(0, shown).map((ch, i) => (
// Plain inline letters (no transform), so long lines still wrap between words only.
<motion.span
key={i}
initial={reduce ? false : { filter: "brightness(2.2)", textShadow: `0 0 18px ${glow}` }}
animate={{ filter: "brightness(1)", textShadow: `0 0 0px ${glow}` }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
{ch}
</motion.span>
))}
{!reduce && shown < letters.length && (
// Zero-width holder, so the caret never pushes the text around.
<span className="relative inline-block w-0">
<motion.span
className="absolute bottom-[-0.1em] left-px h-[0.9em] w-[2px]"
style={{ background: glow, boxShadow: `0 0 8px ${glow}` }}
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 0.8, repeat: Infinity }}
/>
</span>
)}
{/* Untyped letters hold their space, so the line never shifts while typing. */}
<span className="invisible whitespace-pre">{letters.slice(shown).join("")}</span>
</span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/type-nova.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/type-nova-demo.tsx
"use client"; import { useState } from "react"; import { TypeNova } from "@/components/ui/type-nova"; export default function TypeNovaExample() { const [run, setRun] = useState(0); return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_40%,#1d1a33,#0d0c14_70%)] p-6"> <div className="text-center"> <TypeNova key={run} text="Ship something brilliant." className="text-[clamp(26px,4.5vw,48px)] font-semibold tracking-tight text-[#ede9fe]" /> <button type="button" onClick={() => setRun((r) => r + 1)} className="mt-6 block w-full text-xs text-[#ede9fe]/50 hover:text-[#ede9fe]"> Replay </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Text to type. |
| speed | number | 55 | Milliseconds per letter. |
| glow | string | "#a78bfa" | Colour of the flash and the caret. |
| once | boolean | true | Play only the first time it scrolls into view. |
| className | string | "" | Type styles for the text. |