Components

Typewriter

Animates a string or sequence of strings as inline text when it first enters the viewport.

Preview

Interfaces that feel alive.

More examples

Looping phrases

Cycle through an array of phrases with typing, pausing, and deletion between each value.

Fast to compose., Easy to customize., Built for Svelte.

Inline text

Insert the span directly into a sentence without introducing a block-level wrapper.

Build a product that feels remarkably polished from the first interaction.

Installation

Install directly from the hosted registry with the shadcn-svelte CLI.

Terminal
npx shadcn-svelte@latest add https://ui.akcodeworks.com/r/typewriter.json

Usage

  • Place Typewriter anywhere ordinary inline text can appear and pass value as a string or array of strings.
  • The animation begins once when the span first enters the viewport. Set loop to cycle continuously through every supplied phrase.
  • Timing, cursor presentation, and lifecycle callbacks are configurable. Reduced-motion preferences display the final text immediately.

API reference

PropTypeDefaultDescription
valuestring | string[]requiredText or sequence of phrases to type.
loopbooleanfalseDeletes and repeats the supplied phrases continuously.
speednumber (milliseconds)70Delay between each typed or deleted grapheme cluster.
pauseDurationnumber (milliseconds)1200Time a completed phrase remains visible before deletion begins.
startDelaynumber (milliseconds)0Delay after the component enters the viewport before typing starts.
showCursorbooleantrueShows the blinking cursor when enabled.
cursorCharacterstring|Character rendered as the cursor.
ariaLabelstringjoined phrasesStable text exposed to assistive technology instead of incremental updates.
onPhraseChange(value: string, index: number) => void—Called when typing moves to a new phrase.
onComplete() => void—Called when a non-looping sequence finishes.
classstring—Tailwind CSS classes applied to the outer span.
cursorClassstring—Tailwind CSS classes applied to the cursor span.

Source

These files are included in the registry item and copied into the consuming project.

typewriter.svelte
<script module lang="ts">	import type { HTMLAttributes } from 'svelte/elements';	export type TypewriterProps = HTMLAttributes<HTMLSpanElement> & {		value: string | string[];		loop?: boolean;		speed?: number;		pauseDuration?: number;		startDelay?: number;		showCursor?: boolean;		cursorCharacter?: string;		ariaLabel?: string;		onPhraseChange?: (value: string, index: number) => void;		onComplete?: () => void;		cursorClass?: string;		class?: string;	};</script><script lang="ts">	import type { Attachment } from 'svelte/attachments';	import { cn } from '$lib/utils.js';	let {		value,		loop = false,		speed = 70,		pauseDuration = 1200,		startDelay = 0,		showCursor = true,		cursorCharacter = '|',		ariaLabel,		onPhraseChange,		onComplete,		cursorClass,		class: className,		...restProps	}: TypewriterProps = $props();	let displayedText = $state('');	let phraseIndex = 0;	let characterIndex = 0;	let deleting = false;	let timer: ReturnType<typeof setTimeout> | undefined;	let stopped = false;	const phrases = $derived.by(() => {		const entries = Array.isArray(value) ? value : [value];		return entries.length ? entries : [''];	});	const accessibleText = $derived(ariaLabel ?? phrases.join(', '));	function splitText(text: string) {		if (typeof Intl.Segmenter === 'function') {			const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });			return Array.from(segmenter.segment(text), ({ segment }) => segment);		}		return Array.from(text);	}	function clearTimer() {		if (timer === undefined) return;		clearTimeout(timer);		timer = undefined;	}	function schedule(callback: () => void, delay: number) {		clearTimer();		timer = setTimeout(callback, Math.max(0, delay));	}	function finish() {		clearTimer();		onComplete?.();	}	function typeNextCharacter() {		if (stopped) return;		const currentIndex = Math.min(phraseIndex, phrases.length - 1);		const phrase = phrases[currentIndex] ?? '';		const characters = splitText(phrase);		if (!deleting && characterIndex < characters.length) {			characterIndex += 1;			displayedText = characters.slice(0, characterIndex).join('');			schedule(typeNextCharacter, speed);			return;		}		const isLastPhrase = currentIndex === phrases.length - 1;		if (!deleting && !loop && isLastPhrase) {			finish();			return;		}		if (!deleting) {			deleting = true;			schedule(typeNextCharacter, pauseDuration);			return;		}		if (characterIndex > 0) {			characterIndex -= 1;			displayedText = characters.slice(0, characterIndex).join('');			schedule(typeNextCharacter, speed);			return;		}		deleting = false;		phraseIndex = isLastPhrase ? 0 : currentIndex + 1;		onPhraseChange?.(phrases[phraseIndex] ?? '', phraseIndex);		schedule(typeNextCharacter, speed);	}	function start() {		if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {			phraseIndex = loop ? 0 : phrases.length - 1;			displayedText = phrases[phraseIndex] ?? '';			onComplete?.();			return;		}		onPhraseChange?.(phrases[0] ?? '', 0);		schedule(typeNextCharacter, startDelay);	}	const startWhenVisible: Attachment<HTMLSpanElement> = (element) => {		stopped = false;		if (typeof IntersectionObserver === 'undefined') {			start();			return () => {				stopped = true;				clearTimer();			};		}		const observer = new IntersectionObserver(			(entries) => {				if (!entries.some((entry) => entry.isIntersecting)) return;				observer.disconnect();				start();			},			{ threshold: 0.1 }		);		observer.observe(element);		return () => {			stopped = true;			observer.disconnect();			clearTimer();		};	};</script><span	{@attach startWhenVisible}	{...restProps}	data-slot="typewriter"	class={cn('inline', className)}>	<span class="sr-only">{accessibleText}</span>	<span aria-hidden="true">{displayedText}</span>{#if showCursor}<span			aria-hidden="true"			class={cn('typewriter-cursor inline-block', cursorClass)}>{cursorCharacter}</span		>{/if}</span><style>	.typewriter-cursor {		animation: typewriter-cursor-blink 0.8s step-end infinite;	}	@keyframes typewriter-cursor-blink {		50% {			opacity: 0;		}	}	@media (prefers-reduced-motion: reduce) {		.typewriter-cursor {			animation: none;		}	}</style>
index.ts
export { default as Typewriter } from './typewriter.svelte';export type { TypewriterProps } from './typewriter.svelte';