Typewriter
typewriteruiTypes text character-by-character with a blinking caret, optionally cycling through multiple phrases (type -> pause -> delete -> next). Configurable typing/deleting speeds, completion pause, start delay, looping, and caret visibility. Renders empty on the server for hydration-safe SSR, restarts when `phrases` changes, and shows phrases in full under `prefers-reduced-motion`.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/typewriter.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/typewriter.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/typewriter.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/typewriter.jsonnpx shadcn-vue@latest add @uipkge/typewriterInstalls to:app/components/ui/typewriter/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
phrasesA single phrase or a list cycled type -> pause -> delete -> next. | string | string[] | — | required |
typingSpeedMilliseconds per typed character. | number | 45 | optional |
deletingSpeedMilliseconds per deleted character. | number | 25 | optional |
pauseMilliseconds a completed phrase holds before deleting. | number | 1600 | optional |
startDelayMilliseconds before the first character types. | number | 0 | optional |
loopWhen false, stops after fully typing the last phrase (caret keeps blinking). | boolean | true | optional |
showCaret | boolean | true | optional |
class | HTMLAttributes['class'] | — | optional |
Files installed (2)
app/components/ui/typewriter/Typewriter.vue3.3 kB
<script setup lang="ts"> import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { /** A single phrase or a list cycled type -> pause -> delete -> next. */ phrases: string | string[] /** Milliseconds per typed character. */ typingSpeed?: number /** Milliseconds per deleted character. */ deletingSpeed?: number /** Milliseconds a completed phrase holds before deleting. */ pause?: number /** Milliseconds before the first character types. */ startDelay?: number /** When false, stops after fully typing the last phrase (caret keeps blinking). */ loop?: boolean showCaret?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { typingSpeed: 45, deletingSpeed: 25, pause: 1600, startDelay: 0, loop: true, showCaret: true, }) const phraseList = computed(() => (Array.isArray(props.phrases) ? props.phrases : [props.phrases])) const srText = computed(() => phraseList.value.join('. ')) // Starts empty on server AND client first paint; sequencing begins in // onMounted only, so SSR markup and hydration output always match. const text = ref('') const reduced = ref(false) let timer: ReturnType<typeof setTimeout> | null = null function clearTimer() { if (timer !== null) { clearTimeout(timer) timer = null } } function schedule(fn: () => void, delay: number) { clearTimer() timer = setTimeout(() => { timer = null fn() }, delay) } function run() { clearTimer() const list = phraseList.value if (list.length === 0) return let index = 0 let chars = 0 let deleting = false function step() { const current = list[index] ?? '' if (!deleting) { chars += 1 text.value = current.slice(0, chars) if (chars < current.length) { schedule(step, props.typingSpeed) } else if (props.loop || index < list.length - 1) { schedule(() => { deleting = true step() }, props.pause) } // loop=false on the last phrase: stop here; the caret keeps blinking. } else { chars -= 1 text.value = current.slice(0, chars) if (chars > 0) { schedule(step, props.deletingSpeed) } else { deleting = false index = (index + 1) % list.length step() } } } if (props.startDelay > 0) schedule(step, props.startDelay) else step() } onMounted(() => { reduced.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches if (reduced.value) { // Skip the animation entirely: render the phrase in full with a static caret. text.value = phraseList.value[0] ?? '' return } run() }) // Restart the sequence when the phrases prop changes. watch( () => props.phrases, () => { if (reduced.value) { text.value = phraseList.value[0] ?? '' return } run() }, ) onBeforeUnmount(clearTimer) </script> <template> <span data-uipkge data-slot="typewriter" :class="cn(props.class)"> <span class="sr-only">{{ srText }}</span> <span aria-hidden="true" class="whitespace-pre-wrap" ><span>{{ text }}</span ><span v-if="props.showCaret" :class="['inline-block h-[1em] w-[0.5ch] bg-current align-baseline', !reduced && 'animate-caret-blink']" /></span> </span> </template>app/components/ui/typewriter/index.ts0.1 kB
export { default as Typewriter } from './Typewriter.vue'
Raw manifest:https://uipkge.dev/r/vue/typewriter.json