# Typewriter

> Typewriter text effect for React and shadcn/ui that types a phrase, then loops rotating words with a blinking cursor.

Source: https://www.cult-ui.com/docs/components/typewriter

## Example

```tsx title="typewriter-demo.tsx"
"use client"

import { ReactNode } from "react"

import { Typewriter } from "../ui/typewriter"

const texts = [
  "Testing 124",
  "Look at newcult.co",
  "and check gnow.io",
  "Sick af",
]

export default function TypewriterDemo() {
  return (
    <IosOgShellCard>
      <div className="mb-3 ml-auto rounded-2xl bg-blue-500 px-4 py-2 text-white">
        <p className="text-base-900 truncate text-sm font-semibold md:text-base">
          <Typewriter texts={texts} delay={1} baseText="Yo " />
        </p>
      </div>
    </IosOgShellCard>
  )
}

function IosOgShellCard({ children }: { children: ReactNode }) {
  return (
    <div className="shadow-inner-shadow mx-auto flex max-w-xs flex-col rounded-lg bg-neutral-900 px-px pb-px md:max-w-xl md:min-w-80">
      <div className="flex flex-col p-4 md:px-5">
        <div className="mb-2 text-sm text-neutral-500 md:text-neutral-500">
          iMessage
        </div>
        <div className="mb-3 text-xs text-neutral-500 md:text-sm">
          Today 11:29
        </div>
        <div className="mb-3 ml-auto rounded-2xl bg-blue-500 px-4 py-2 text-white">
          <span>Hey!</span>
        </div>
        <div className="mr-auto mb-3 rounded-2xl bg-neutral-700 px-4 py-2 text-white">
          <span>Whats up bretheren?!</span>
        </div>
        {children}
        <div className="mt-3 text-xs text-neutral-500 md:text-sm">
          Delivered
        </div>
      </div>
    </div>
  )
}
```

Typewriter is a React text component for shadcn/ui built with Motion. Use it for landing page headlines, hero taglines, or AI product intros.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/typewriter
```

### Manual

**Copy and paste the following code into your project.**

```tsx title="typewriter.tsx"
"use client"

import { useEffect, useState } from "react"
import { animate, motion, useMotionValue, useTransform } from "motion/react"

export interface ITypewriterProps {
  delay: number
  texts: string[]
  baseText?: string
}

export function Typewriter({ delay, texts, baseText = "" }: ITypewriterProps) {
  const [animationComplete, setAnimationComplete] = useState(false)
  const count = useMotionValue(0)
  const rounded = useTransform(count, (latest) => Math.round(latest))
  const displayText = useTransform(rounded, (latest) =>
    baseText.slice(0, latest)
  )

  useEffect(() => {
    const controls = animate(count, baseText.length, {
      type: "tween",
      delay,
      duration: 1,
      ease: [0.42, 0, 0.58, 1] as const,
      onComplete: () => setAnimationComplete(true),
    })
    return () => {
      controls.stop && controls.stop()
    }
  }, [count, baseText.length, delay])

  return (
    <span>
      <motion.span>{displayText}</motion.span>
      {animationComplete && (
        <RepeatedTextAnimation texts={texts} delay={delay + 1} />
      )}
      <BlinkingCursor />
    </span>
  )
}

export interface IRepeatedTextAnimationProps {
  delay: number
  texts: string[]
}

const defaultTexts = [
  "quiz page with questions and answers",
  "blog Article Details Page Layout",
  "ecommerce dashboard with a sidebar",
  "ui like platform.openai.com....",
  "buttttton",
  "aop that tracks non-standard split sleep cycles",
  "transparent card to showcase achievements of a user",
]
function RepeatedTextAnimation({
  delay,
  texts = defaultTexts,
}: IRepeatedTextAnimationProps) {
  const textIndex = useMotionValue(0)

  const baseText = useTransform(textIndex, (latest) => texts[latest] || "")
  const count = useMotionValue(0)
  const rounded = useTransform(count, (latest) => Math.round(latest))
  const displayText = useTransform(rounded, (latest) =>
    baseText.get().slice(0, latest)
  )
  const updatedThisRound = useMotionValue(true)

  useEffect(() => {
    const animation = animate(count, 60, {
      type: "tween",
      delay,
      duration: 1,
      ease: [0.42, 0, 1, 1] as const,
      repeat: Infinity,
      repeatType: "reverse",
      repeatDelay: 1,
      onUpdate(latest) {
        if (updatedThisRound.get() && latest > 0) {
          updatedThisRound.set(false)
        } else if (!updatedThisRound.get() && latest === 0) {
          textIndex.set((textIndex.get() + 1) % texts.length)
          updatedThisRound.set(true)
        }
      },
    })
    return () => {
      animation.stop && animation.stop()
    }
  }, [count, delay, textIndex, texts, updatedThisRound])

  return <motion.span className="inline">{displayText}</motion.span>
}

const cursorVariants = {
  blinking: {
    opacity: [0, 0, 1, 1],
    transition: {
      duration: 1,
      repeat: Infinity,
      repeatDelay: 0,
      ease: [0, 0, 1, 1] as const,
      times: [0, 0.5, 0.5, 1],
    },
  },
}

function BlinkingCursor() {
  return (
    <motion.div
      variants={cursorVariants}
      animate="blinking"
      className="inline-block h-5 w-[1px] translate-y-1 bg-neutral-900"
    />
  )
}
```

**Update the import paths to match your project setup.**

## Usage

```tsx
const texts = [
  "Testing 124",
  "Look at newcult.co",
  "and check gnow.io",
  "Sick af",
];

export default function TypewriterDemo() {
  return (
    <IosOgShellCard>
      <div className="ml-auto px-4 py-2 mb-3 text-white bg-blue-500 rounded-2xl">
        <p className="text-sm md:text-base font-semibold text-base-900 truncate">
          <Typewriter texts={texts} delay={1} baseText="Yo " />
        </p>
      </div>
    </IosOgShellCard>
  );
}
```
