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 })

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

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