threlte logo
@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:

PropertyTypeDescription
pressedbooleanWhether the key is currently held down
justPressedbooleanWhether the key was first pressed this frame
justReleasedbooleanWhether 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.