threlte logo
@threlte/extras

useGamepad

useGamepad provides frame-accurate gamepad state tracking using the Standard Gamepad layout. Button state is polled each frame, giving you pressed, justPressed, and justReleased on every button — just like useKeyboard.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import type { StandardGamepad } from '@threlte/extras'
  import { Pane, Folder, Slider, ButtonGrid } from 'svelte-tweakpane-ui'
  import Scene from './Scene.svelte'

  let gamepadRef = $state<StandardGamepad>()

  const buttonNames = [
    'clusterBottom',
    'clusterRight',
    'clusterLeft',
    'clusterTop',
    'leftBumper',
    'rightBumper',
    'select',
    'start',
    'leftStickButton',
    'rightStickButton',
    'directionalTop',
    'directionalBottom',
    'directionalLeft',
    'directionalRight',
    'center'
  ] as const

  const stickNames = ['leftStick', 'rightStick'] as const

  const buttonLabels = $derived(
    gamepadRef
      ? buttonNames.map((name) => (gamepadRef?.button(name).pressed ? `▶ ${name}` : name))
      : buttonNames.map((name) => name)
  )
</script>

<Pane
  title=""
  position="fixed"
>
  <ButtonGrid
    buttons={buttonLabels}
    columns={2}
    disabled
  />

  {#if gamepadRef}
    <Folder title="Triggers">
      <Slider
        value={gamepadRef.button('leftTrigger').value}
        label="LT"
        min={0}
        max={1}
        disabled
      />
      <Slider
        value={gamepadRef.button('rightTrigger').value}
        label="RT"
        min={0}
        max={1}
        disabled
      />
    </Folder>

    <Folder title="Sticks">
      {#each stickNames as name}
        <Slider
          value={gamepadRef.stick(name).x}
          label="{name}X"
          min={-1}
          max={1}
          disabled
        />
        <Slider
          value={gamepadRef.stick(name).y}
          label="{name}Y"
          min={-1}
          max={1}
          disabled
        />
      {/each}
    </Folder>
  {/if}
</Pane>

<div>
  <Canvas>
    <Scene bind:gamepadRef />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Edges, HTML, Text, useGamepad, useTexture } from '@threlte/extras'
  import { fly } from 'svelte/transition'
  import { Spring } from 'svelte/motion'
  import { Color } from 'three'

  let { gamepadRef = $bindable() }: { gamepadRef?: any } = $props()

  const gamepad = useGamepad()
  gamepadRef = gamepad
  const { connected } = gamepad
  const logo = useTexture('/icons/mstile-150x150.png')

  // Buttons and sticks via the new API
  const dUp = gamepad.button('directionalTop')
  const dDown = gamepad.button('directionalBottom')
  const dLeft = gamepad.button('directionalLeft')
  const dRight = gamepad.button('directionalRight')
  const btnA = gamepad.button('clusterBottom')
  const btnB = gamepad.button('clusterRight')
  const btnX = gamepad.button('clusterLeft')
  const btnY = gamepad.button('clusterTop')
  const lt = gamepad.button('leftTrigger')
  const rt = gamepad.button('rightTrigger')
  const sel = gamepad.button('select')
  const btnStart = gamepad.button('start')

  const bodyColor = '#eedbcb'
  const buttonColor = '#111111'
  const activeColor = '#ff3e00'
  const triggerColor = '#555555'
  const activeTextColor = 'white'

  const dep = -0.06
  const springOpts = { stiffness: 0.3, damping: 0.6 }

  // D-pad springs
  const dUpZ = Spring.of(() => (dUp.pressed ? dep : 0), springOpts)
  const dDownZ = Spring.of(() => (dDown.pressed ? dep : 0), springOpts)
  const dLeftZ = Spring.of(() => (dLeft.pressed ? dep : 0), springOpts)
  const dRightZ = Spring.of(() => (dRight.pressed ? dep : 0), springOpts)

  // Action button springs
  const aZ = Spring.of(() => (btnA.pressed ? dep : 0), springOpts)
  const bZ = Spring.of(() => (btnB.pressed ? dep : 0), springOpts)
  const xZ = Spring.of(() => (btnX.pressed ? dep : 0), springOpts)
  const yZ = Spring.of(() => (btnY.pressed ? dep : 0), springOpts)

  // Center button springs
  const selZ = Spring.of(() => (sel.pressed ? dep : 0), springOpts)
  const startZ = Spring.of(() => (btnStart.pressed ? dep : 0), springOpts)

  const trackedButtons = [
    { state: btnA, label: 'A' },
    { state: btnB, label: 'B' },
    { state: btnX, label: 'X' },
    { state: btnY, label: 'Y' },
    { state: lt, label: 'LT' },
    { state: rt, label: 'RT' },
    { state: dUp, label: 'Up' },
    { state: dDown, label: 'Down' },
    { state: dLeft, label: 'Left' },
    { state: dRight, label: 'Right' },
    { state: sel, label: 'Select' },
    { state: btnStart, label: 'Start' }
  ] as const

  // Layout
  const dpadX = -2
  const clusterX = 2
  const padY = 0
  const s = 0.6
  const bh = 0.25
  const bodyDepth = 0.8
  const front = bodyDepth / 2 + bh / 2
  const top = 1.5
  const textZ = bh / 2 + 0.01

  const color1 = new Color()
  const color2 = new Color()
</script>

<T.OrthographicCamera
  zoom={70}
  position={[7, 5, 8]}
  makeDefault
  oncreate={(ref) => ref.lookAt(2, 0, 0)}
/>

<T.AmbientLight intensity={0.6} />
<T.DirectionalLight
  position={[3, 5, 5]}
  intensity={0.8}
/>

<!-- Controller body -->
<T.Mesh>
  <T.BoxGeometry args={[7, 3, bodyDepth]} />
  <T.MeshStandardMaterial color={bodyColor} />
  <Edges
    color="black"
    scale={1.001}
  />
</T.Mesh>

<!-- Threlte logo -->
{#if $logo}
  <T.Mesh position={[0, 0.2, front + 0.01]}>
    <T.PlaneGeometry args={[1.5, 1.5]} />
    <T.MeshStandardMaterial
      map={$logo}
      transparent
    />
  </T.Mesh>
{/if}

<!-- Triggers -->
<T.Mesh position={[-2.25, top + 0.1 - lt.value * 0.1, 0]}>
  <T.BoxGeometry args={[1.8, 0.2, 0.5]} />
  <T.MeshStandardMaterial
    color={color1.set(triggerColor).lerp(color2.set(activeColor), lt.value).getHex()}
  />
  <Edges color="black" />
  <Text
    text="LT"
    fontSize={0.18}
    color={lt.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0.11, 0]}
    rotation={[-Math.PI / 2, 0, 0]}
  />
</T.Mesh>

<T.Mesh position={[2.25, top + 0.1 - rt.value * 0.1, 0]}>
  <T.BoxGeometry args={[1.8, 0.2, 0.5]} />
  <T.MeshStandardMaterial
    color={color1.set(triggerColor).lerp(color2.set(activeColor), rt.value).getHex()}
  />
  <Edges color="black" />
  <Text
    text="RT"
    fontSize={0.18}
    color={rt.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0.11, 0]}
    rotation={[-Math.PI / 2, 0, 0]}
  />
</T.Mesh>

<!-- D-pad -->
<T.Mesh position={[dpadX, padY + 0.6, front + dUpZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={dUp.touched ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text={'\u25B2'}
    fontSize={0.25}
    color={dUp.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[dpadX, padY - 0.6, front + dDownZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={dDown.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text={'\u25BC'}
    fontSize={0.25}
    color={dDown.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[dpadX - 0.6, padY, front + dLeftZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={dLeft.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text={'\u25C0'}
    fontSize={0.25}
    color={dLeft.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[dpadX + 0.6, padY, front + dRightZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={dRight.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text={'\u25B6'}
    fontSize={0.25}
    color={dRight.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<!-- D-pad center -->
<T.Mesh position={[dpadX, padY, front]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={buttonColor} />
</T.Mesh>

<!-- Action buttons -->
<T.Mesh position={[clusterX, padY - 0.6, front + aZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={btnA.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text="A"
    fontSize={0.3}
    color={btnA.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[clusterX + 0.65, padY, front + bZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={btnB.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text="B"
    fontSize={0.3}
    color={btnB.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[clusterX - 0.65, padY, front + xZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={btnX.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text="X"
    fontSize={0.3}
    color={btnX.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<T.Mesh position={[clusterX, padY + 0.6, front + yZ.current]}>
  <T.BoxGeometry args={[s, s, bh]} />
  <T.MeshStandardMaterial color={btnY.pressed ? activeColor : buttonColor} />
  <Edges color="black" />
  <Text
    text="Y"
    fontSize={0.3}
    color={btnY.pressed ? activeTextColor : bodyColor}
    anchorX="center"
    anchorY="middle"
    position={[0, 0, textZ]}
  />
</T.Mesh>

<!-- Center buttons -->
<T.Mesh position={[-0.45, -0.8, front + selZ.current]}>
  <T.BoxGeometry args={[0.7, 0.3, 0.15]} />
  <T.MeshStandardMaterial color={sel.pressed ? activeColor : '#888888'} />
  <Edges color="black" />
  <Text
    text="SEL"
    fontSize={0.15}
    color="#111111"
    anchorX="center"
    anchorY="middle"
    position={[0, 0, 0.08]}
  />
</T.Mesh>

<T.Mesh position={[0.45, -0.8, front + startZ.current]}>
  <T.BoxGeometry args={[0.7, 0.3, 0.15]} />
  <T.MeshStandardMaterial color={btnStart.pressed ? activeColor : '#888888'} />
  <Edges color="black" />
  <Text
    text="START"
    fontSize={0.15}
    color="#111111"
    anchorX="center"
    anchorY="middle"
    position={[0, 0, 0.08]}
  />
</T.Mesh>

<Text
  text={$connected ? 'Gamepad connected' : 'Connect a gamepad'}
  fontSize={0.25}
  color={$connected ? '#4a9' : '#888888'}
  anchorX="center"
  anchorY="middle"
  position={[0, -2.25, 0]}
/>

<T.Group position={[0, -3.5, 0]}>
  <HTML
    center
    transform={false}
  >
    <div class="states">
      {#each trackedButtons as button}
        {#if button.state.justPressed}
          <span
            class="badge just-pressed"
            out:fly={{ y: 20, duration: 400 }}>{button.label} justPressed</span
          >
        {/if}
        {#if button.state.justReleased}
          <span
            class="badge just-released"
            out:fly={{ y: 20, duration: 400 }}>{button.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 { useGamepad } from '@threlte/extras'

  const gamepad = useGamepad()
  const jump = gamepad.button('clusterBottom')

  useTask(
    () => {
      if (jump.justPressed) {
        console.log('Jump!')
      }

      const left = gamepad.stick('leftStick')
      console.log(left.x, left.y)
    },
    { after: gamepad.task }
  )
</script>

More than one gamepad can be connected at any given time, so an optional index can be specified.

const gamepad1 = useGamepad({ index: 0 })
const gamepad2 = useGamepad({ index: 1 })

Button State

Call gamepad.button(name) to get the state of a button. The returned object is stable — calling gamepad.button('clusterBottom') always returns the same reference, so you can store it:

const a = gamepad.button('clusterBottom')
const lt = gamepad.button('leftTrigger')

// In a useTask callback:
if (a.justPressed) jump()
if (lt.value > 0.5) accelerate()
PropertyTypeDescription
pressedbooleanWhether the button is currently held down
justPressedbooleanWhether the button was first pressed this frame
justReleasedbooleanWhether the button was released this frame
touchedbooleanWhether the button is being touched (if supported)
valuenumberAnalog value 0–1 (e.g. triggers)

Stick State

Call gamepad.stick(name) to get a stick’s axis values:

const left = gamepad.stick('leftStick')
const right = gamepad.stick('rightStick')

console.log(left.x, left.y)
PropertyTypeDescription
xnumberHorizontal axis (-1 left, 1 right)
ynumberVertical axis (-1 up, 1 down)

Reactivity

Button and stick properties are reactive, so you can use them directly in your template or in $derived expressions:

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

  const gamepad = useGamepad()
  const a = gamepad.button('clusterBottom')
  const left = gamepad.stick('leftStick')
</script>

<p>{a.pressed ? 'A pressed!' : 'A not pressed'}</p>
<p>Left stick: ({left.x.toFixed(2)}, {left.y.toFixed(2)})</p>

Event Listeners

Event listeners can be attached to individual buttons, sticks, or the gamepad itself.

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

  const gamepad = useGamepad()

  // Listen on a specific button
  const off = gamepad.button('leftTrigger').on('down', (event) => {
    console.log('Left trigger pressed!')
    off() // Unsubscribe after first fire
  })

  // Listen on a stick
  gamepad.stick('leftStick').on('change', (event) => {
    console.log(`Left stick: ${event.value.x}, ${event.value.y}`)
  })

  // Listen on all buttons
  gamepad.on('press', (event) => {
    console.log(`${event.target} pressed: ${event.value}`)
  })
</script>

Available events:

EventDescription
downFires when a button press begins
upFires when a button press ends
pressFires when a button is pressed (after release)
changeFires when a button or stick value changes
touchstartFires when a touch begins (if supported by the gamepad)
touchendFires when a touch ends
touchFires when a touch completes (after release)

Task Ordering

useGamepad polls gamepad state in a named task ('useGamepad'). To ensure your game logic reads the latest state, schedule your task after it:

useTask(
  () => {
    // gamepad state is up to date here
  },
  { after: gamepad.task }
)

Options

index

The gamepad index when multiple gamepads are connected. Defaults to 0.

axisDeadzone

The minimum axis value before change events fire. Defaults to 0.05.

const gamepad = useGamepad({ axisDeadzone: 0.1 })

mappings

A table of per-controller remap overrides. Custom entries take precedence over the built-in mappings. See Non-Standard Controllers for the full shape and examples.

useBuiltinMappings

When false, skip the built-in mapping table and use only the entries provided via mappings. Defaults to true.

Connection Status

The connected property is a currentWritable that updates when a gamepad connects or disconnects.

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

  const gamepad = useGamepad()
  const { connected } = gamepad
</script>

<p>{$connected ? 'Gamepad connected' : 'No gamepad'}</p>

The raw unmapped Gamepad can be accessed via gamepad.raw. This will be null unless a gamepad is connected.

Gamepad data is fetched as an immutable snapshot on every frame, so any variable that caches gamepad.raw will become stale on the next frame.

Button Mapping

The gamepad maps 17 standard buttons and 2 sticks:

Buttons:

  • Right cluster: clusterBottom, clusterRight, clusterLeft, clusterTop
  • Bumpers and triggers: leftBumper, rightBumper, leftTrigger, rightTrigger
  • Center: select, start, center
  • Stick buttons: leftStickButton, rightStickButton
  • D-pad: directionalTop, directionalBottom, directionalLeft, directionalRight

Sticks:

  • leftStick, rightStick

Non-Standard Controllers

Xbox and PlayStation pads usually report Gamepad.mapping === "standard" and line up with the standard gamepad layout. Many other controllers don’t — most Nintendo pads report an empty mapping string and hand the browser their buttons in a different order. That’s why clusterBottom can end up firing on the wrong press, or why buttons look stuck at rest on a pad that’s sitting idle.

To keep position-based names like clusterBottom meaning “the south face button” regardless of brand, @threlte/extras ships a small built-in remap table and applies it automatically to non-standard pads it recognises. Currently covered:

  • Nintendo Switch Pro Controller
  • Nintendo Switch Online SNES Controller
  • Nintendo Joy-Con (L) and Joy-Con (R)

Non-standard pads that aren’t in the table log a one-time warning with their Gamepad.id and read through the standard indices, which will usually be wrong.

Adding your own mapping

Pass a mappings object keyed by vvvv:pppp — the USB vendor and product IDs parsed from Gamepad.id, lowercase. Anything you leave unset falls back to the standard layout, so you only need to describe the inputs that differ on your hardware.

const gamepad = useGamepad({
  mappings: {
    '2dc8:3106': {
      buttons: { rightTrigger: { button: 15 } }
    }
  }
})

A mapping entry can remap buttons ({ button: n }, or { axis: n, range: 'signed' } for triggers that come through as an axis), sticks ({ xAxis, yAxis } with optional invertX / invertY), and — for pads that expose the d-pad as a single hat axis — a dpad entry listing which axis values correspond to each direction.

XR Gamepad

For WebXR controllers with the xr-standard layout, set xr: true:

const left = useGamepad({ xr: true, hand: 'left' })
const right = useGamepad({ xr: true, hand: 'right' })

left.button('trigger').on('change', (event) => console.log(event))
right.button('trigger').on('change', (event) => console.log(event))

XR Buttons: trigger, squeeze, touchpadButton, thumbstickButton, clusterBottom, clusterTop

XR Sticks: touchpad, thumbstick