---
title: Typed Mark
description: Types a word out one character at a time, cycling the syntax palette. No JavaScript, no state, and it runs with scripting off.
source: https://adamjurek.com/components/typed-mark
---

## Home


A word typed out one character at a time in the CLI's syntax colours, built
entirely in CSS with no JavaScript and no state - the markup renders once and
the animation is a stagger delay per character. Reach for it for a short name
or phrase that should feel alive without costing a re-render.

<!-- ::start:showcase demo="typed-mark" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The obvious build is a `useState` counter and a `setInterval`, which costs a
re-render per character, can't render on the server, and shows the whole word
for one frame before the effect even runs. This renders the finished markup
once and lets CSS decide when each character becomes visible, so the server
and the client agree, the animation survives JavaScript being off, and a
five-character word costs the same as a fifty-character one.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/typed-mark.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/typed-mark.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | motion · Text effects |
| Files | `typed-mark.tsx` |
| Dependencies | None |
| Tags | type, css-only, no-js, a11y, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { TypedMark } from "@sushindustries/ui";

export function Example() {
	return <TypedMark text="sushi industries" />;
}
```

## What you should see

The words appear one character at a time, roughly 55ms apart, each one
landing in the next colour of the CLI palette. There is no flash of the whole
word first - the markup is final on arrival, and it is the CSS animation
delay alone that staggers the reveal. Reload with JavaScript disabled and the
word is simply there, in full colour, all at once.

## If nothing happens

If every character appears at once in the same colour instead of staggered
and cycling, the atoms stylesheet is not loaded - this mark has no fallback
styling of its own, it depends entirely on `.typed` and `.typed-char` from
`packages/atoms`.


## Guides


## Composing it

`typed` is `inline-flex`, so it sits mid-sentence like any inline element -
inside a heading, inside a button label, wherever a word would go. It
preserves its own spaces (`white-space: pre`), which a plain inline-flex row
would otherwise collapse.

## Two marks, one page

`offset` shifts where a mark starts in the nine-colour cycle. Two marks
beginning at the same hue read as a repeat rather than as a set:

```tsx
<TypedMark text="sushi industries" />
<TypedMark text="one class, one job" offset={4} />
```

This is the real pairing from this site's own demo: the second mark starts
four hues around the wheel from the first, so the two visibly disagree
instead of echoing each other.

## Motion and reduced motion

Under `prefers-reduced-motion: reduce` the per-character animation is
removed outright, which restores the default `opacity: 1` - the word appears
whole and in full colour immediately, rather than typing itself out. Nothing
is hidden by the preference; only the reveal is.

## When not to use it

Every character is rendered as its own DOM element, so cost scales with
length - this is for a name or a short phrase, not a sentence. And because
the visible characters are `aria-hidden`, a screen reader gets `label ??
text` instead: pass `label` whenever the spoken word should differ from the
one on screen.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `text` | `string` | - | The word. Rendered per character, so keep it short. |
| `offset?` | `number` | `0` | Where in the colour cycle to begin. Two marks on one page starting at the same hue read as a repeat rather than as a set, and this is what makes them differ without a second palette. |
| `className?` | `string` | - |  |
| `label?` | `string` | - | What a screen reader announces. Every character is its own element here, and left alone a screen reader would spell the word out. The characters are hidden and this is read instead, so the mark is a word to everybody. |

<!-- /generated:api -->

## Notes

The nine colours are `--syn-*`, the CLI's own palette, checked against
`--code-bg` rather than against the page ground - all nine clear 4.5:1 there,
where the page's accent colour manages 2.71:1. Using this mark somewhere
other than the terminal slab it was designed for risks lower contrast than
the palette promises.

`className` is appended after the built-in `typed` class, so it can add
spacing or positioning but cannot usefully override the per-character colour
rules - those are keyed to `nth-child`, not to a class the wrapper carries.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="typed-mark" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

The site's assistant panel hands its mark straight to a `mark` prop, so the
name types itself in every new conversation:

```tsx
import { AssistantPanel, TypedMark } from "@sushindustries/ui";

export function Assistant() {
	return (
		<AssistantPanel
			mark={<TypedMark text="sushi industries" />}
			messages={messages}
			onSend={ask}
			placeholder="Ask about this site"
		/>
	);
}
```

## What this example is not

`AssistantPanel` treats `mark` as an opaque `ReactNode` - it does not know
this is a `TypedMark` and would render any other node just as happily. The
pairing here is a choice made at the call site, not a contract between the
two components.
