@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:
| Property | Type | Description |
|---|---|---|
pressed | boolean | Whether any binding for this action is active |
justPressed | boolean | Whether the action became active this frame |
justReleased | boolean | Whether the action became inactive this frame |
strength | number | Analog 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>