TypeWriter
TypeWriter is a component that simulates typing and deleting text, creating a typewriter effect.
Hi! I'm a
Hi! I'm a
Hi! I'm a
Installation
Install Dependencies
npm i clsx tailwind-merge framer-motion
Create @/utils/cn.ts file
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Typewriter Component
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import React, { useEffect, useState } from "react";
const typewriterVariants = cva("relative", {
variants: {
variant: {
default: "",
thick: "",
nocaret: "after:hidden"
}
},
defaultVariants: {
variant: "default"
}
});
export interface TypeWriterProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof typewriterVariants> {
words: string[];
typeSpeed?: number;
deleteSpeed?: number;
delayBetweenWords?: number;
}
const TypeWriter: React.FC<TypeWriterProps> = ({
words,
typeSpeed = 150,
deleteSpeed = 100,
delayBetweenWords = 1000,
className,
variant,
...props
}) => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mql.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
const animationCSS = prefersReducedMotion
? words
.map(
(word, index) => `
${index * 25}%, ${(index + 1) * 25}% { content: "${word}"; }
`
)
.join("\n")
: words
.map((word, i) => {
const previousWords = words.slice(0, i);
const start = previousWords.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0);
const typing = word
.split("")
.map((_, charIndex) => {
const charStart = start + charIndex * typeSpeed;
const percentage =
(charStart / words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0)) * 100;
return `${percentage}% { content: "${word.slice(0, charIndex + 1)}"; }`;
})
.join("\n");
const fullWord = start + word.length * typeSpeed;
const deleteStart = fullWord + delayBetweenWords;
const deleting = word
.split("")
.map((_, charIndex) => {
const charStart = deleteStart + charIndex * deleteSpeed;
const percentage =
(charStart / words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0)) * 100;
return `${percentage}% { content: "${word.slice(0, word.length - charIndex - 1)}"; }`;
})
.join("\n");
return `${typing}\n${deleting}`;
})
.join("\n");
const totalDuration = prefersReducedMotion
? words.length * 3000
: words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0);
return (
<>
<style jsx>{`
@keyframes typing {
${animationCSS}
}
@keyframes blink {
0%,
100% {
border-color: transparent;
}
50% {
border-color: currentColor;
}
}
`}</style>
<span
className={typewriterVariants({ variant, className })}
style={
{
"--totalDuration": `${totalDuration}ms`
} as React.CSSProperties
}
{...props}
>
<span
className={`before:animate-[typing_var(--totalDuration)_steps(1,end)_infinite] after:ml-[1px] after:animate-[blink_0.7s_steps(1,end)_infinite] after:border-r after:border-current after:content-[''] ${variant === "thick" ? "after:border-r-[1ch]" : ""} ${variant === "nocaret" ? "after:hidden" : ""} `}
></span>
</span>
</>
);
};
export default TypeWriter;
Examples
Default
Hi! I'm a
Thick
Hi! I'm a
No Caret
Hi! I'm a