threlte logo
@threlte/extras

useInputMap

useInputMap provides an action mapping system that abstracts physical inputs into named actions. Define actions like "jump" or "moveLeft" once, bind them to keyboard keys and gamepad buttons, then query them by name. This decouples game logic from specific input devices.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import { Pane, List, Text } from 'svelte-tweakpane-ui'
  import Scene from './Scene.svelte'

  const sprintKeyOptions = {
    Shift: 'Shift',
    Space: 'Space',
    e: 'e'
  }
  let sprintKey: keyof typeof sprintKeyOptions = $state('Shift')
  let activeDevice = $state('keyboard')
</script>

<Pane
  title="Input"
  position="fixed"
>
  <List
    bind:value={sprintKey}
    options={sprintKeyOptions}
    label="sprint key"
  />
  <Text
    value={activeDevice}
    label="device"
    disabled
  />
</Pane>

<div>
  <Canvas>
    <Scene
      {sprintKey}
      bind:activeDevice
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { GLTF, useGltfAnimations } from '@threlte/extras'

  type Props = {
    action: 'idle' | 'run' | 'walk'
  }
  let { action = 'idle' }: Props = $props()

  let { gltf, actions } = useGltfAnimations()
  let currentAction = 'idle'

  $effect(() => {
    $actions?.['idle']?.play()
  })

  $effect(() => {
    transitionTo(action, 0.2)
  })

  function transitionTo(next: string, duration = 0.2) {
    const current = $actions[currentAction]
    const nextAnim = $actions[next]
    if (!nextAnim || current === nextAnim) return
    nextAnim.enabled = true
    if (current) {
      current.crossFadeTo(nextAnim, duration, true)
    }
    nextAnim.play()
    currentAction = next
  }
</script>

<GLTF
  bind:gltf={$gltf}
  url="https://threejs.org/examples/models/gltf/Xbot.glb"
  oncreate={(scene) => {
    scene.traverse((child) => {
      child.castShadow = true
    })
  }}
/>
<script lang="ts">
  import { MathUtils } from 'three'
  import { T, useTask } from '@threlte/core'
  import { useInputMap, useGamepad, useKeyboard, Grid, HTML } from '@threlte/extras'
  import Character from './Character.svelte'

  let {
    sprintKey = 'Shift',
    activeDevice = $bindable('keyboard')
  }: { sprintKey?: string; activeDevice?: string } = $props()

  const keyboard = useKeyboard()
  const gamepad = useGamepad()

  const input = useInputMap(
    ({ key, gamepadAxis, gamepadButton }) => ({
      moveLeft: [key('a'), key('ArrowLeft'), gamepadAxis('leftStick', 'x', -1)],
      moveRight: [key('d'), key('ArrowRight'), gamepadAxis('leftStick', 'x', 1)],
      moveForward: [key('w'), key('ArrowUp'), gamepadAxis('leftStick', 'y', -1)],
      moveBack: [key('s'), key('ArrowDown'), gamepadAxis('leftStick', 'y', 1)],
      sprint: [key(sprintKey), gamepadButton('leftBumper')]
    }),
    { keyboard, gamepad }
  )

  // Prevent arrow keys from scrolling the page
  keyboard.on('keydown', (e) => {
    if (e.key.startsWith('Arrow')) e.preventDefault()
  })

  $effect(() => {
    activeDevice = input.activeDevice.current
  })

  const sprinting = $derived(input.action('sprint').pressed)
  const moveX = $derived(input.axis('moveLeft', 'moveRight'))
  const moveY = $derived(input.axis('moveForward', 'moveBack'))
  const moving = $derived(moveX !== 0 || moveY !== 0)
  const action = $derived<'idle' | 'run' | 'walk'>(moving ? (sprinting ? 'run' : 'walk') : 'idle')

  let x = $state(0)
  let z = $state(0)
  let rotation = $state(0)
  let targetRotation = 0

  const rotationSpeed = 10
  const sprintSpeed = 4
  const walkSpeed = 2

  useTask(
    (delta) => {
      const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
      const speed = sprinting ? sprintSpeed : walkSpeed

      x += move.x * speed * delta
      z += move.y * speed * delta

      // Smoothly rotate character to face movement direction
      if (moving) {
        targetRotation = Math.atan2(move.x, move.y)
      }

      // Lerp rotation using shortest path around the circle
      let diff = targetRotation - rotation
      // Wrap to [-PI, PI] so we always take the shortest turn
      diff = MathUtils.euclideanModulo(diff + Math.PI, Math.PI * 2) - Math.PI
      rotation += diff * Math.min(1, rotationSpeed * delta)
    },
    { after: input.task }
  )
</script>

<T.PerspectiveCamera
  position={[0, 4, 5]}
  oncreate={(ref) => ref.lookAt(0, 1, 0)}
  makeDefault
  fov={50}
/>

<T.DirectionalLight
  position={[5, 10, 5]}
  intensity={1.5}
  castShadow
/>
<T.AmbientLight intensity={0.4} />

<Grid
  cellColor="#444444"
  sectionColor="#ff3e00"
  sectionSize={5}
  cellSize={1}
  gridSize={[20, 20]}
  fadeDistance={25}
/>

<T.Mesh
  rotation.x={-Math.PI / 2}
  position.y={-0.01}
  receiveShadow
>
  <T.CircleGeometry args={[15, 72]} />
  <T.MeshStandardMaterial color="white" />
</T.Mesh>

<T.Group
  position.x={x}
  position.z={z}
  rotation.y={rotation}
>
  <Character {action} />
</T.Group>

<T.Group
  position.x={x}
  position.y={2.5}
  position.z={z}
>
  <HTML
    center
    transform={false}
  >
    <div class="overlay">
      {#if input.activeDevice.current === 'keyboard'}
        <p class="hint">WASD / Arrows to move, {sprintKey} to sprint</p>
      {:else}
        <p class="hint">Left Stick to move, LB to sprint</p>
      {/if}

      <div class="info">
        <span class="label">vector</span>
        <span class="value">({moveX.toFixed(2)}, {moveY.toFixed(2)})</span>
      </div>

      <div
        class="badge"
        class:sprint={sprinting}
        class:walk={moving && !sprinting}
      >
        {action}
      </div>
    </div>
  </HTML>
</T.Group>

<style>
  .overlay {
    font-family: 'Inter', system-ui, sans-serif;
    color: white;
    text-align: center;
    user-select: none;
    pointer-events: none;
    width: 260px;
  }

  .hint {
    font-size: 12px;
    opacity: 0.6;
    margin: 0 0 8px;
  }

  .info {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    font-size: 13px;
  }

  .label {
    opacity: 0.5;
    font-weight: 600;
  }

  .value {
    font-family: 'JetBrains Mono', monospace;
  }

  .badge {
    display: inline-block;
    margin-top: 6px;
    font-size: 11px;
    padding: 2px 10px;
    border-radius: 4px;
    font-weight: 600;
    background: rgba(255, 255, 255, 0.15);
  }

  .sprint {
    background: rgba(255, 62, 0, 0.8);
  }

  .walk {
    background: rgba(74, 144, 217, 0.8);
  }
</style>
<script lang="ts">
  import { useTask } from '@threlte/core'
  import { useInputMap, useKeyboard, useGamepad } from '@threlte/extras'

  const keyboard = useKeyboard()
  const gamepad = useGamepad()

  const input = useInputMap(
    ({ key, gamepadButton, gamepadAxis }) => ({
      jump: [key('Space'), gamepadButton('clusterBottom')],
      moveLeft: [key('a'), gamepadAxis('leftStick', 'x', -1)],
      moveRight: [key('d'), gamepadAxis('leftStick', 'x', 1)],
      moveForward: [key('w'), gamepadAxis('leftStick', 'y', -1)],
      moveBack: [key('s'), gamepadAxis('leftStick', 'y', 1)]
    }),
    { keyboard, gamepad }
  )

  useTask(
    () => {
      if (input.action('jump').justPressed) {
        player.jump()
      }

      const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
      player.velocity.x = move.x * speed
      player.velocity.z = move.y * speed
    },
    { after: input.task }
  )
</script>

Reactive Definitions

The definitions are passed as a function, so you can reactively update bindings at runtime — for example, when the user remaps controls in a settings screen:

<script lang="ts">
  import { useInputMap, useKeyboard } from '@threlte/extras'

  const keyboard = useKeyboard()
  let jumpKey = $state('Space')

  const input = useInputMap(
    ({ key }) => ({
      jump: [key(jumpKey)]
    }),
    { keyboard }
  )

  // Later, when the user remaps:
  jumpKey = 'j'
</script>

Bindings

Each action maps to an array of bindings. Any active binding triggers the action. Three binding helpers are passed to the definitions callback:

key(key)

Binds a keyboard key by its KeyboardEvent.key value. Matching is case-insensitive, so 'w' matches both 'w' and 'W' (when Shift is held).

key('Space') // spacebar
key('w') // W key
key('ArrowUp') // up arrow
key('Shift') // either shift key

gamepadButton(button)

Binds a standard gamepad button. Uses the same button names as useGamepad.

gamepadButton('clusterBottom') // A / Cross
gamepadButton('clusterRight') // B / Circle
gamepadButton('leftTrigger') // LT / L2
gamepadButton('leftBumper') // LB / L1

gamepadAxis(stick, axis, direction, threshold?)

Binds a gamepad stick axis in a specific direction. The threshold parameter controls the minimum axis value before the binding activates (default 0.1).

gamepadAxis('leftStick', 'x', 1) // right on left stick
gamepadAxis('leftStick', 'x', -1) // left on left stick
gamepadAxis('leftStick', 'y', -1) // up on left stick (y is inverted)
gamepadAxis('leftStick', 'y', 1) // down on left stick
gamepadAxis('rightStick', 'x', 1, 0.2) // right on right stick, 0.2 deadzone

Action State

Call input.action(name) to get the current state of an action:

PropertyTypeDescription
pressedbooleanWhether any binding for this action is active
justPressedbooleanWhether the action became active this frame
justReleasedbooleanWhether the action became inactive this frame
strengthnumberAnalog strength 0–1. Digital inputs produce 0 or 1.
const jump = input.action('jump')

if (jump.justPressed) startJump()
if (jump.pressed) holdJump()
if (jump.justReleased) releaseJump()

strength is useful for analog inputs like gamepad triggers, where you might want partial values. For keyboard keys, strength is always 0 or 1.

Action state properties are reactive, so you can use them directly in your template:

<p>{input.action('sprint').pressed ? 'Sprinting!' : 'Walking'}</p>

Axes and Vectors

axis(negative, positive)

Combines two actions into a signed axis value from -1 to 1. This is equivalent to Godot’s Input.get_axis().

const horizontal = input.axis('moveLeft', 'moveRight') // -1 to 1
const vertical = input.axis('moveBack', 'moveForward') // -1 to 1

vector(negativeX, positiveX, negativeY, positiveY)

Combines four actions into a 2D vector, clamped to a unit circle (magnitude ≤ 1). This is equivalent to Godot’s Input.get_vector() and handles diagonal normalization automatically.

const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
// move.x: -1 to 1
// move.y: -1 to 1
// magnitude is clamped to 1 (no faster diagonal movement)

vector() returns the same object reference each call to avoid allocations in the game loop. Don’t store it across frames — read the values immediately.

Active Device

input.activeDevice.current is a reactive property that returns 'keyboard' or 'gamepad', based on whichever device most recently provided input. Can be used, for example, to show context-sensitive button prompts:

{#if input.activeDevice.current === 'keyboard'}
  <p>Press Space to jump</p>
{:else}
  <p>Press A to jump</p>
{/if}

Options

keyboard

A useKeyboard instance for resolving keyboard bindings.

import { useKeyboard, useInputMap } from '@threlte/extras'

const keyboard = useKeyboard()

const input = useInputMap(
  ({ key }) => ({
    jump: [key('Space')]
  }),
  { keyboard }
)

gamepad

Pass a useGamepad return value to enable gamepad bindings. Only required if any action uses gamepadButton() or gamepadAxis().

import { useKeyboard, useGamepad, useInputMap } from '@threlte/extras'

const keyboard = useKeyboard()
const gamepad = useGamepad()

const input = useInputMap(
  ({ key, gamepadButton }) => ({
    jump: [key('Space'), gamepadButton('clusterBottom')]
  }),
  { keyboard, gamepad }
)

Task Ordering

useInputMap runs its processing task after the internal keyboard task. Schedule your game logic after input.task:

useTask(
  () => {
    // All action states are up to date here
  },
  { after: input.task }
)

Keyboard-Only Example

If you don’t need gamepad support, just use key() bindings:

<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { useInputMap, useKeyboard } from '@threlte/extras'

  const keyboard = useKeyboard()

  const input = useInputMap(
    ({ key }) => ({
      jump: [key('Space')],
      moveLeft: [key('a'), key('ArrowLeft')],
      moveRight: [key('d'), key('ArrowRight')],
      moveForward: [key('w'), key('ArrowUp')],
      moveBack: [key('s'), key('ArrowDown')],
      sprint: [key('Shift')]
    }),
    { keyboard }
  )

  let x = $state(0)
  let z = $state(0)

  useTask(
    (delta) => {
      const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
      const speed = input.action('sprint').pressed ? 10 : 5

      x += move.x * speed * delta
      z += move.y * speed * delta
    },
    { after: input.task }
  )
</script>

<T.Mesh
  position.x={x}
  position.z={z}
>
  <T.BoxGeometry />
  <T.MeshStandardMaterial color="orange" />
</T.Mesh>