@threlte/extras
useKeyboard
useKeyboard provides frame-accurate keyboard state tracking for game-style input.
Key presses that happen between frames are collected and applied together at the start of each frame, giving you
reliable justPressed and justReleased states that last exactly one frame — just
like Input.is_action_just_pressed() in Godot.
<script lang="ts">
import { Canvas } from '@threlte/core'
import Scene from './Scene.svelte'
</script>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import { T } from '@threlte/core'
import { Edges, HTML, Text, useKeyboard } from '@threlte/extras'
import { fade } from 'svelte/transition'
import { Spring } from 'svelte/motion'
const keyboard = useKeyboard()
const w = keyboard.key('w')
const a = keyboard.key('a')
const s = keyboard.key('s')
const d = keyboard.key('d')
const space = keyboard.key('Space')
const trackedKeys = [
{ key: w, label: 'W' },
{ key: a, label: 'A' },
{ key: s, label: 'S' },
{ key: d, label: 'D' },
{ key: space, label: 'Space' }
] as const
const activeColor = '#ff3e00'
const inactiveColor = '#1a1a1a'
const activeTextColor = 'white'
const inactiveTextColor = '#aaaaaa'
const edgeColor = 'rgba(255, 255, 255, 0.2)'
const activeEdgeColor = '#ff3e00'
const dep = -0.1
const springOpts = { stiffness: 0.3, damping: 0.6 }
const wZ = Spring.of(() => (w.pressed ? dep : 0), springOpts)
const aZ = Spring.of(() => (a.pressed ? dep : 0), springOpts)
const sZ = Spring.of(() => (s.pressed ? dep : 0), springOpts)
const dZ = Spring.of(() => (d.pressed ? dep : 0), springOpts)
const spaceZ = Spring.of(() => (space.pressed ? dep : 0), springOpts)
</script>
<T.OrthographicCamera
zoom={90}
position={[5, 5, 8]}
makeDefault
oncreate={(ref) => {
ref.lookAt(0, 0, 0)
}}
/>
<T.AmbientLight intensity={0.6} />
<T.DirectionalLight
position={[5, 5, 5]}
intensity={0.8}
/>
<!-- W key -->
<T.Group
position.x={0}
position.y={1.15}
position.z={wZ.current}
>
<T.Mesh scale.y={0.9}>
<T.BoxGeometry args={[1, 1, 0.3]} />
<T.MeshStandardMaterial color={w.pressed ? activeColor : inactiveColor} />
<Edges color={w.pressed ? activeEdgeColor : edgeColor} />
</T.Mesh>
<Text
text="W"
fontSize={0.4}
color={w.pressed ? activeTextColor : inactiveTextColor}
anchorX="center"
anchorY="middle"
position.z={0.16}
/>
</T.Group>
<!-- A key -->
<T.Group
position.x={-1.1}
position.y={0}
position.z={aZ.current}
>
<T.Mesh scale.y={0.9}>
<T.BoxGeometry args={[1, 1, 0.3]} />
<T.MeshStandardMaterial color={a.pressed ? activeColor : inactiveColor} />
<Edges color={a.pressed ? activeEdgeColor : edgeColor} />
</T.Mesh>
<Text
text="A"
fontSize={0.4}
color={a.pressed ? activeTextColor : inactiveTextColor}
anchorX="center"
anchorY="middle"
position.z={0.16}
/>
</T.Group>
<!-- S key -->
<T.Group
position.x={0}
position.y={0}
position.z={sZ.current}
>
<T.Mesh scale.y={0.9}>
<T.BoxGeometry args={[1, 1, 0.3]} />
<T.MeshStandardMaterial color={s.pressed ? activeColor : inactiveColor} />
<Edges color={s.pressed ? activeEdgeColor : edgeColor} />
</T.Mesh>
<Text
text="S"
fontSize={0.4}
color={s.pressed ? activeTextColor : inactiveTextColor}
anchorX="center"
anchorY="middle"
position.z={0.16}
/>
</T.Group>
<!-- D key -->
<T.Group
position.x={1.1}
position.y={0}
position.z={dZ.current}
>
<T.Mesh scale.y={0.9}>
<T.BoxGeometry args={[1, 1, 0.3]} />
<T.MeshStandardMaterial color={d.pressed ? activeColor : inactiveColor} />
<Edges color={d.pressed ? activeEdgeColor : edgeColor} />
</T.Mesh>
<Text
text="D"
fontSize={0.4}
color={d.pressed ? activeTextColor : inactiveTextColor}
anchorX="center"
anchorY="middle"
position.z={0.16}
/>
</T.Group>
<!-- Space key -->
<T.Group
position.x={0}
position.y={-1.15}
position.z={spaceZ.current}
>
<T.Mesh scale.y={0.9}>
<T.BoxGeometry args={[3.2, 1, 0.3]} />
<T.MeshStandardMaterial color={space.pressed ? activeColor : inactiveColor} />
<Edges color={space.pressed ? activeEdgeColor : edgeColor} />
</T.Mesh>
<Text
text="Space"
fontSize={0.35}
color={space.pressed ? activeTextColor : inactiveTextColor}
anchorX="center"
anchorY="middle"
position.z={0.16}
/>
</T.Group>
<!-- Hint text -->
<Text
text="Press WASD or Space"
fontSize={0.25}
color="#666666"
anchorX="center"
anchorY="middle"
position={[0, 2.4, 0]}
/>
<!-- Badge area using HTML for transitions -->
<T.Group position={[0, -2.4, 0]}>
<HTML
center
transform={false}
>
<div class="states">
{#each trackedKeys as { key, label }}
{#if key.justPressed}
<span
class="badge just-pressed"
out:fade={{ duration: 400 }}>{label} justPressed</span
>
{:else if key.justReleased}
<span
class="badge just-released"
out:fade={{ duration: 400 }}>{label} justReleased</span
>
{/if}
{/each}
</div>
</HTML>
</T.Group>
<style>
.states {
min-height: 24px;
display: grid;
justify-items: center;
font-family: 'Inter', system-ui, sans-serif;
user-select: none;
pointer-events: none;
}
.badge {
grid-row: 1;
grid-column: 1;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.just-pressed {
background: rgba(0, 200, 80, 0.7);
}
.just-released {
background: rgba(200, 80, 0, 0.7);
}
</style>
<script lang="ts">
import { useTask } from '@threlte/core'
import { useKeyboard } from '@threlte/extras'
const keyboard = useKeyboard()
useTask(
() => {
if (keyboard.key('Space').justPressed) {
console.log('Jump!')
}
if (keyboard.key('w').pressed) {
console.log('Moving forward...')
}
},
{ after: keyboard.task }
)
</script>
Key State
Each key is identified by its
KeyboardEvent.key
value (e.g. 'w', 'Space', 'ArrowUp', 'Shift'). Matching is case-insensitive, so
'w' matches both 'w' and 'W' (when Shift is held). Call keyboard.key(name) to
get a KeyState object:
| Property | Type | Description |
|---|---|---|
pressed | boolean | Whether the key is currently held down |
justPressed | boolean | Whether the key was first pressed this frame |
justReleased | boolean | Whether the key was released this frame |
The KeyState object is stable — calling keyboard.key('Space') always returns the
same object reference, so you can store it:
<script lang="ts">
import { useTask } from '@threlte/core'
import { useKeyboard } from '@threlte/extras'
const keyboard = useKeyboard()
const space = keyboard.key('Space')
const w = keyboard.key('w')
useTask(
() => {
if (space.justPressed) jump()
if (w.pressed) moveForward()
},
{ after: keyboard.task }
)
</script>
Reactivity
KeyState properties are reactive, so you can use them directly in your template
or in $derived expressions without a game loop:
<script lang="ts">
import { useKeyboard } from '@threlte/extras'
const keyboard = useKeyboard()
const space = keyboard.key('Space')
</script>
<p>{space.pressed ? 'Jumping!' : 'On the ground'}</p>
Event Listeners
For immediate (non-polling) reactions, use on(). Events fire as soon as the browser
delivers them, before frame processing.
<script lang="ts">
import { useKeyboard } from '@threlte/extras'
const keyboard = useKeyboard()
keyboard.on('keydown', (e) => {
console.log(`Pressed: ${e.key}`)
})
const off = keyboard.on('keyup', (e) => {
console.log(`Released: ${e.key}`)
})
// Stop listening:
// off()
</script>
Task Ordering
useKeyboard processes buffered events in a named task ('useKeyboard'). To ensure
your game logic reads the latest keyboard state, schedule your task after it:
useTask(
() => {
// keyboard state is up to date here
},
{ after: keyboard.task }
)
Options
target
The DOM element to listen on. Defaults to window, which captures keyboard input
globally regardless of focus. Pass a specific element if you want scoped input:
import { useThrelte } from '@threlte/core'
const { dom } = useThrelte()
const keyboard = useKeyboard(() => ({ target: dom }))
Focus Handling
When the window loses focus (blur), all pressed keys are automatically released. This prevents stuck keys when the user alt-tabs away while holding a key.