Playground
Types its text out, character by character, behind a blinking cursor: a hero line, a terminal prompt, a tagline that keeps changing. It takes its font, size and colour from where you put it.
Put the text inside it. That is what search engines and readers without JavaScript see, and what screen readers announce, all at once rather than letter by letter. The finished text is laid out from the start, invisibly, so the line breaks never change and nothing around it moves while it types. It starts when it comes into view and pauses while it is out of it; with reduced motion, the text is there at once.
Examples
A command line
prompt is there from the start, the text after it types out. The cursor stays and blinks when it is done.
<p style="font-size: 1.125rem; margin: 0">
<sb-typewriter prompt="$ " interval="35">starbase deploy --to orbit</sb-typewriter>
</p>
Phrases
phrases types each phrase in turn and erases it before the next; loop starts over after the last. The element takes the size of the longest phrase from the start, so a heading under it never jumps. Keep the text inside it: it is the one readers get without JavaScript, and the one screen readers announce.
Next stop:
the Moon.
<h3 style="font-size: 2rem; margin: 0">
Next stop:
<sb-typewriter phrases='["the Moon.","Mars.","Europa.","wherever the data goes."]' loop hold="1400" style="color: var(--sb-brand-light)">the Moon.</sb-typewriter>
</h3>
sb-typed fires each time a phrase is complete, with its text and index; with reduced motion, for the phrase shown, and again when new text arrives.
Cursors
cursor is block (the default), bar, underscore or none.
<div style="display: grid; gap: 8px; font-size: 1.25rem">
<sb-typewriter cursor="block">Block cursor</sb-typewriter>
<sb-typewriter cursor="bar">Bar cursor</sb-typewriter>
<sb-typewriter cursor="underscore">Underscore cursor</sb-typewriter>
<sb-typewriter cursor="none">No cursor</sb-typewriter>
</div>
Text from the server
New text types again: a morph that changes the element's text, or data-text from a signal. Here the buttons stand in for the server.
<div data-signals="{_status: 'Awaiting telemetry…'}" style="display: grid; gap: 12px; justify-items: start">
<p style="margin: 0"><sb-typewriter prompt="> " interval="25" data-text="$_status">Awaiting telemetry…</sb-typewriter></p>
<div style="display: flex; gap: 8px">
<sb-button size="sm" data-on:click="$_status = 'Signal acquired. 4 satellites in view.'">Acquire</sb-button>
<sb-button size="sm" variant="ghost" data-on:click="$_status = 'Signal lost.'">Lose</sb-button>
</div>
</div>
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Fonts and size: the text uses your page's font, size and colour; it sets none of its own.
- Colours:
--sb-typewriter-cursorcolours the cursor (by default--sb-brand-light). - Parts:
text(the whole line),promptandcursor. The cursor moves withtranslate, so the page never counts a layout shift while it types. Your page's::part()rules win over the component's own, without!important.
<style>
.my-type { --sb-typewriter-cursor: #FFD166; font: 600 1.5rem Georgia, serif; }
.my-type::part(prompt) { color: #FFD166; }
</style>
<sb-typewriter class="my-type" prompt="» " cursor="bar">A quieter kind of terminal.</sb-typewriter>
Accessibility
Screen readers get the element's text in one piece, once: the typing, the cursor and the prompt are hidden from them. With phrases they get the element's own text rather than every phrase, so keep it meaningful. With prefers-reduced-motion, nothing types, cycles or blinks: the first phrase is there from the start.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-typewriter prompt="$ ">Ready for launch.</sb-typewriter>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/typewriter@46120294284f/typewriter.min.js": "sha384-f6UPnozveAKP4ydQG6UyMUifqg86AyrMvVWBmrktnU0aMkd0LLsNXx8npBGBKeCA"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/typewriter@46120294284f/typewriter.min.js" integrity="sha384-f6UPnozveAKP4ydQG6UyMUifqg86AyrMvVWBmrktnU0aMkd0LLsNXx8npBGBKeCA"></script>
<sb-typewriter prompt="$ ">Ready for launch.</sb-typewriter>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/typewriter@46120294284f/typewriter.min.js": "sha384-f6UPnozveAKP4ydQG6UyMUifqg86AyrMvVWBmrktnU0aMkd0LLsNXx8npBGBKeCA"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@c165875fb681/autoloader.js" integrity="sha384-GSnXFTGY2G0KaLoU4ay3eO2eDT2deAiOwFYw6q+cyzLfqQzEKw47znHC9xsLt/VR"></script>
<sb-typewriter prompt="$ ">Ready for launch.</sb-typewriter>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/typewriter/typewriter.min.js"></script>
<sb-typewriter prompt="$ ">Ready for launch.</sb-typewriter>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
typewriter.js | 9.4 kB | 3.8 kB | 3.2 kB | 1.9 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
phrases | json | [] | Phrases to type in turn, each erased before the next, as a JSON array of strings. Without it, the element's own text is typed once. |
loop | boolean | false | With phrases: start over after the last one, for as long as the element is on screen. |
prompt | string | "" | Text in front of the typed text that is there from the start, e.g. "$ ". |
cursor | "block" | "bar" | "underscore" | "none" | "block" | The cursor's shape. It blinks while it waits. |
interval | number | 45 | Average milliseconds between two characters: lower types faster. Each varies a little, like a person typing; erasing takes half as long. |
delay | number | 400 | Milliseconds before the first character, counted from when the element comes into view. |
hold | number | 1800 | With phrases: milliseconds a typed phrase stays before it is erased. |
Events
| Name | Description |
|---|---|
sb-typed | When a phrase is fully typed. detail: { text, index }. |