Docs
Type Writer
Type Writer
A Type Writer effect to your texts.
We are Unizoy
Install the following dependencies:
pnpm add gsap @gsap/react
Make a file for cn and mergerRef functions and match the import afterwards
import clsx, { ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes))
export function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(node)
} else if (ref && "current" in ref) {
;(ref as React.MutableRefObject<T | null>).current = node
}
})
}
}
Add this configurations to tailwind.config.js
file
extend:{
keyframes:{
'blink-border':{
'0%,70%,100%': {
"borderColor": 'white'
},
'20%,50%': {
"borderColor": 'black'
}
}
},
animation:{
'blink-border' : 'blink-border 1s infinite'
}
}
Make a file and copy paste this code in a file with name type-writer.tsx
"use client"
import { forwardRef, useRef, HTMLAttributes, useEffect, useState } from "react"
import gsap from "gsap"
import { cn, mergeRefs } from "@/lib/utils"
import { useGSAP } from "@gsap/react"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger)
type TextAndClass = {
text: string
className?: string
}
interface TypeWriterProps extends HTMLAttributes<HTMLDivElement> {
staticText: TextAndClass[]
textArray?: TextAndClass[]
delay?: number
duration?: number
ease?: gsap.EaseString | gsap.EaseFunction
className?: string
start?: string | number | ((tag?: ScrollTrigger) => string | number)
end?: string | number | ((tag?: ScrollTrigger) => string | number)
arrayInterval?: number
deleteSpeed?: number
}
const TypeWriter = forwardRef<HTMLDivElement, TypeWriterProps>(
(
{
staticText,
textArray,
children,
delay = 0,
start = "top 90%",
end = "top",
duration = 0.5,
ease = "none",
className = "",
arrayInterval = 3000,
deleteSpeed = 0.1,
...props
},
ref
) => {
const containerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const [currentArrayText, setCurrentArrayText] = useState("")
const [arrayIndex, setArrayIndex] = useState(0)
const [isDeleting, setIsDeleting] = useState(false)
const [mainTextComplete, setMainTextComplete] = useState(false)
// Handle the main text animation
useGSAP(
() => {
if (!textRef.current) return
const animation = gsap.from(textRef.current, {
width: 0,
duration: duration || staticText.length * 0.3,
delay,
ease,
onComplete: () => setMainTextComplete(true),
scrollTrigger: {
trigger: textRef.current,
start,
end,
toggleActions: "play none none reset",
onLeaveBack: () => setMainTextComplete(false),
},
})
return () => {
animation.kill()
}
},
{
dependencies: [staticText, delay, start, end, duration, ease],
scope: containerRef,
}
)
// Handle the textArray animations
useEffect(() => {
if (!textArray?.length || !mainTextComplete) return
let timeout: ReturnType<typeof setTimeout>
let isActive = true // Flag to prevent state updates after unmount
const animateText = () => {
if (!isActive) return
if (isDeleting) {
if (currentArrayText.length > 0) {
setCurrentArrayText((prev) => prev.slice(0, -1))
timeout = setTimeout(animateText, deleteSpeed * 1000)
} else {
setIsDeleting(false)
setArrayIndex((prev) => (prev + 1) % textArray.length)
}
} else {
const targetText = textArray[arrayIndex].text
if (currentArrayText.length < targetText.length) {
setCurrentArrayText((prev) => targetText.slice(0, prev.length + 1))
timeout = setTimeout(animateText, deleteSpeed * 1000)
} else {
timeout = setTimeout(
() => isActive && setIsDeleting(true),
arrayInterval
)
}
}
}
timeout = setTimeout(
animateText,
currentArrayText.length === 0 ? 0 : deleteSpeed * 1000
)
return () => {
isActive = false
clearTimeout(timeout)
}
}, [
textArray,
arrayIndex,
currentArrayText,
isDeleting,
arrayInterval,
deleteSpeed,
mainTextComplete,
])
// Reset array text when main animation resets
useEffect(() => {
if (!mainTextComplete) {
setCurrentArrayText("")
setArrayIndex(0)
setIsDeleting(false)
}
}, [mainTextComplete])
return (
<div
ref={mergeRefs(ref, containerRef)}
className={cn("flex flex-col gap-2", className)}
{...props}
>
<div className="flex items-center animate-blink-border border-r-2 pr-1">
<div ref={textRef} className="flex text-nowrap overflow-hidden ">
{staticText.map((data, i) => (
<span key={`staticText-${i}`} className={cn(data.className)}>
{data.text}
</span>
))}
</div>
{textArray && (
<div className={cn(textArray[arrayIndex]?.className)}>
{currentArrayText}
</div>
)}
</div>
</div>
)
}
)
TypeWriter.displayName = "TypeWriter"
export { TypeWriter }
Props
Name | Type | Description |
---|---|---|
staticText | Array<{ text: string, className?: string }> | An array of objects containing text and optional class names. |
textArray | Array<{ text: string, className?: string }> | An array of strings used for dynamic text rendering. |
children | ReactNode | React children to be rendered inside the component. |
delay | number | Delay before the animation starts (in seconds). Default is 0 . |
start | string | number | () => string | number | Animation start point, GSAP docs. Default is "top 90%" . |
end | string | number | () => string | number | Animation end point, GSAP docs. Default is "top" . |
duration | number | Duration of the animation (in seconds). Default is 0.5 . |
ease | gsap.EaseString| gsap.EaseFunction | Easing function for animation, GSAP docs. Default is "none" . |
className | string | Additional CSS classes for styling. Default is an empty string. |
arrayInterval | number | Time interval (in milliseconds) between each text change. Default is 3000 . |
deleteSpeed | number | Speed (in seconds) at which text is deleted during animations. Default is 0.1 . |
props | object | Any additional props passed to the component. |