Ghost Typewriter
A keyboard broken into six fragments, hanging in the air around a headline that types itself. Each keycap presses as its letter lands. Drag a fragment anywhere, spin it with the right button, roll it with the wheel — then read the arrangement back out as coordinates. Type on your own keyboard and the caps answer.
Included with membership
Members install Ghost Typewriter with one command and get the full source, the usage code and the Framer remix, licensed for client work. One membership covers the whole library and the CLI.
When to use it
The top of a page you want remembered — a homepage hero, a product launch, a landing page where the headline is the whole pitch. It wants the full width of the screen and a good deal of height. One per page: it takes the keyboard, and two of them would fight over it.
- Keep title text short and single-word if you can. It is set very large and uppercase, and the layout is built around one word landing in the middle.
- Use variant="paragraph" for anything conversational. It switches to a smaller editorial setting and a quicker default cadence, and auto picks it for you once the text runs past twenty characters or contains a space.
- Set captureKeyboard={false} if the page has anything else keyboard-driven below it. Left on, any keystroke on the page presses a cap.
- Load the display face yourself and name it in fontFamily. The default stack asks for Plus Jakarta Sans and falls back to the system UI face if you have not loaded it.
- Arrange the fragments by hand, then read them back with the ref's getLayout() and paste the result into layout. That is what the drag, spin and wheel controls are for.
Avoid:
- Do not pass a long paragraph with autoPlay on and a slow typingSpeed. At the default title speed a forty-character sentence takes half a minute to finish and then waits before starting again.
- Do not expect every character to have a cap. The fragments carry A–Z, space and backspace; anything else — digits, punctuation, accents — presses a random cap instead, which is deliberate but looks arbitrary if the text is full of them.
- Do not put interactive controls in children without pointer-events-auto. The overlay is pointer-transparent so the fragments stay draggable underneath it.
- Do not drive text from state that changes every keystroke of a real form. Changing text resets the typing sequence to the first character.
- Do not use it as a section in the middle of a long page with captureKeyboard left on. Someone reading further down who presses a key will set an off-screen keyboard clattering.
Props
A mechanical keyboard taken apart and left hanging in the air — six fragments, each a circuit board under a sheet of frosted polycarbonate, with real keycaps on switch stems. A headline types itself in the middle, and the matching cap presses as each letter lands. Type on your own keyboard and the caps answer. Every fragment can be dragged, spun with the right button and rolled with the wheel, and the arrangement you end up with can be read back out as coordinates and passed straight back in.
<GhostTypewriter />
The scene, the typed headline and all the interaction. Paints its own background and fills its container. Also exported as SuspendedKeycapTyping; the two names are the same component.
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | "ANTICIPATION" | What gets typed. Changing it restarts the sequence from the first character. |
subtitle | string | — | Small monospaced kicker above the headline. Omit it and nothing is rendered. |
variant | "title" | "paragraph" | "auto" | "auto" | How the text is set. "title" is large and uppercase, "paragraph" is smaller editorial prose. "auto" picks paragraph once the text passes twenty characters or contains a space. |
theme | "studio" | "obsidian" | "jade" | "studio" | Colour preset. studio is a warm off-white room, obsidian near-black, jade a dark green. Sets the background, text, board colour, shell tint and the colour a cap flashes when pressed. |
typingSpeed | "slow" | "normal" | "fast" | "rapid" | number | 650ms for title, 140ms for paragraph | Milliseconds per keystroke, or a preset — slow 850, normal 400, fast 160, rapid 90. The default depends on which variant is in play. |
loopDelay | number | 2400 | How long the finished text is held before it clears and types again. |
autoPlay | boolean | true | Runs the typing sequence. Turned off, the text stays as given and only what someone types changes it. |
interactive | boolean | true | Dragging, spinning and rolling the fragments. Turned off they still float and follow the pointer, but cannot be moved. |
captureKeyboard | boolean | true | Whether typing anywhere on the page drives the keycaps. Text fields and editable regions are always left alone; only characters, space and backspace are picked up. |
layout | KeyboardFragmentLayout[] | built-in arrangement | Starting positions and rotations, by fragment id. Only the ids you supply are overridden. Pair it with getLayout() to save an arrangement you liked. |
backgroundColor | string | theme preset | Colour behind the scene. |
textColor | string | theme preset | Colour of the headline, subtitle and caret. |
fontFamily | string | "Plus Jakarta Sans", ui-sans-serif, system-ui, … | CSS font stack for the headline. Load the face however your project loads fonts and name it here; the default falls back to the system UI face. |
onKeyPress | (char: string) => void | — | Fires on every keystroke, typed by the sequencer or by a person. Useful for driving sound. |
onTextChange | (newText: string) => void | — | Fires when someone editing by hand changes the string. Only with autoPlay off — that is the mode where typing edits the text rather than scrubbing through it. |
onLayoutChange | (layout: KeyboardFragmentLayout[]) => void | — | Fires when a fragment finishes being dragged, spun or rolled. |
children | React.ReactNode | — | Overlay content above the headline, in a pointer-transparent layer. Anything clickable needs pointer-events-auto of its own. |
disableAdaptiveQuality | boolean | false | Renders at full pixel density regardless of the device probe. Left off, weaker hardware drops density and turns shadows off. |
className | string | "" | Applied to the wrapper. |
style | React.CSSProperties | — | Inline styles merged onto the wrapper, after backgroundColor and fontFamily. |
mount | "auto" | "eager" | "auto" | When to start typing. The default waits until the page has frame headroom to spare. Set "eager" when the typing is the headline and the reader should see it begin rather than arrive part-written. Note this defaults the opposite way from the components that build a 3D scene: those start eagerly and take "auto" to defer. Both mean the same thing by "auto" — let the runtime pick the moment — the default just matches what each component already did. Neither setting takes it off the shared frame loop or overrides the reduced-motion preference. |
<GhostTypewriterRef />
typeThe imperative handle, via ref. Also exported as SuspendedKeycapRef. This is how an arrangement made by dragging becomes a layout prop you can commit.
| Prop | Type | Default | Description |
|---|---|---|---|
getLayout* | () => KeyboardFragmentLayout[] | — | Current position and rotation of every fragment, rounded to two decimals so it is fit to paste into source. |
resetLayout* | () => void | — | Returns every fragment to where it started. |
<KeyboardFragmentLayout />
typeOne fragment's placement.
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Which fragment. One of "frag-uiop-bksp", "frag-azx", "frag-rtyfg", "frag-spacebar-combo", "frag-dhjkl", "frag-qwes". |
position* | [number, number, number] | — | Where it sits, as [x, y, z] in scene units. |
rotation* | [number, number, number] | — | How it is turned, as [x, y, z] in radians. |
<SpeedPreset />
typeA typing speed: "slow" (850ms), "normal" (400ms), "fast" (160ms), "rapid" (90ms), or a number of milliseconds.
<TextVariant />
typeTypography mode: "title", "paragraph" or "auto".
<KeycapTheme />
typeColour preset: "studio", "obsidian" or "jade".
ssr: false if your route renders on the server.