# 打字机 Typewriter > 文字一个字一个字打出来,后面跟着闪烁的光标,几句话轮流打。 分类:动效 · 角色:点缀(贴在某个元素上,一页两三个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:首屏标题后半句轮换(帮你做 网站 / App / 小程序);AI 回复。 - 不适合:重要信息只靠打字显示(没打完就看不到);同一屏已经有乱码解码。 - 适合的网站:SaaS、官网、作品集、活动页 - 放在:标题 - 手机上:好用:和电脑上一样。 - 性能代价:低:只改文字内容。 - 常搭配:[流体背景 Fluid Background](https://www.dachuiai.com/c/fluid-background.md)、[点阵波纹 Dot Grid](https://www.dachuiai.com/c/dot-grid.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react`(lucide-react 只有最后的演示示例用到;不留示例的话,删掉对应的 import 就不用装) - 文件:原样存成 `typewriter.tsx`(比如 `src/components/typewriter.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/typewriter.tsx),比手抄准:`curl -fsSL -o src/components/typewriter.tsx https://www.dachuiai.com/code/typewriter.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useTypewriter`、`Caret` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:useTypewriter 传一组句子会循环;只传一句并 loop: false 就是打完停住。 - `lines` 要写成组件外面的常量,或者用 useMemo 包起来:每次渲染都传一个新写的数组,组件会以为内容变了,从头重来。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **useTypewriter** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `lines` | | 要轮流显示的几句话 | | `speed` | `70` | 每个字间隔多少毫秒,越小越快 | | `hold` | `1600` | 一句打完停多久(毫秒) | | `loop` | `true` | 打完删掉再打下一句;只想打完停住传 false | **Caret** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首页大标题做成 Typewriter 打字机效果:一个字一个字打出来,后面跟着闪烁的光标,几句话轮流打。 ## 完整代码 ```tsx import {useEffect, useRef, useState} from 'react'; import {ArrowUp, Sparkles} from 'lucide-react'; /* --------------------------------------------------------------- * Typewriter 打字机 * 文字一个字一个字打出来,后面跟着一闪一闪的光标。 * useTypewriter:传一组句子,会打完一句、停一下、删掉、再打下一句(loop); * 只传一句并 loop=false,就是打完停住(适合 AI 回复)。 * ------------------------------------------------------------- */ export function useTypewriter(lines: string[], {speed = 70, hold = 1600, loop = true} = {}) { const [i, setI] = useState(0); const [n, setN] = useState(0); const [deleting, setDeleting] = useState(false); const line = lines[i % lines.length] ?? ''; const reduce = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; useEffect(() => { setI(0); setN(0); setDeleting(false); }, [lines]); useEffect(() => { if (reduce) return; let t: number; if (!deleting && n < line.length) t = window.setTimeout(() => setN(n + 1), speed * (0.6 + Math.random() * 0.8)); else if (!deleting && loop) t = window.setTimeout(() => setDeleting(true), hold); else if (deleting && n > 0) t = window.setTimeout(() => setN(n - 1), speed * 0.4); else if (deleting) { setDeleting(false); setI(i + 1); } return () => clearTimeout(t!); }, [n, deleting, line, loop, speed, hold, i, reduce]); return {text: reduce ? line : line.slice(0, n), typing: !reduce && (n < line.length || deleting)}; } export function Caret({className = ''}: {className?: string}) { return ( <> ); } /* ---------------- 示例:首页标题 + AI 回复 ---------------- */ const HEADLINES = ['让每个人都能用 AI 做产品', '一句话,生成一个网页', '说对名字,AI 一次改对']; const REPLIES: Record = { default: '好的,我先帮你把首页做出来:顶部导航、一张大图和三个卖点卡片。', }; function Reply({text}: {text: string}) { const lines = useRef([text]).current; const t = useTypewriter(lines, {speed: 45, loop: false}); return <>{t.text}{t.typing && }; } export default function TypewriterDemo() { const head = useTypewriter(HEADLINES); const [msgs, setMsgs] = useState<{me: boolean; text: string}[]>([{me: true, text: '帮我做一个首页'}, {me: false, text: REPLIES.default}]); const [draft, setDraft] = useState(''); const send = () => { const q = draft.trim(); if (!q) return; setMsgs((m) => [...m, {me: true, text: q}, {me: false, text: `收到:「${q}」。我先改好给你看。`}]); setDraft(''); }; return (

{head.text}

{msgs.map((m, i) => m.me ? (
{m.text}
) : (
{i === msgs.length - 1 ? : m.text}
), )}
{ e.preventDefault(); send(); }} className="mx-8 mb-7 flex h-14 items-center gap-2 rounded-full bg-white/[.05] pl-6 pr-2 ring-1 ring-white/10 focus-within:ring-2 focus-within:ring-sky-400/50"> setDraft(e.target.value)} placeholder="输入你的想法…" className="flex-1 bg-transparent text-[15px] text-white outline-none placeholder:text-white/35" />
); } ```