# Text fields and keyboard handling in FiveM NUI

> Fix keyboard bugs in FiveM NUI forms: failing autofocus, Escape opening the pause menu, typing that moves the player, key repeat, Enter, Tab and UTF-8.

Source: https://fam-tools.com/guides/fivem-nui-text-input-keyboard · FAM Tools · published 2026-09-19 · updated 2026-09-19 · about 11 min

**Short answer.** Focus fields only after the UI is visible and NUI focus is set, using `requestAnimationFrame` or a short timeout. Close on `keyup`, not `keydown`, and block the pause controls for a moment after releasing focus so the game does not act on the same Escape. Turn keep input off while a field is focused. Ignore `event.repeat` for single-shot shortcuts, handle Enter through a real `<form>` submit, contain Tab inside your dialog, and validate text length in characters with `utf8.len` on the server, because Lua string length counts bytes.

## Key points

- `element.focus()` fails on hidden elements; focus after showing, in the next frame.
- Close on `keyup`; then disable controls 199 and 200 for a few frames in Lua.
- No keep input while typing, or the player walks and opens chat.
- Check `event.repeat` so a held key does not fire ten times.
- Use `<form>` submit for Enter; trap Tab inside modal dialogs.
- `#str` counts bytes; use `utf8.len` for character limits.

Forms are where NUI stops feeling like a web page. The input that should be focused is not, Escape closes your dialog and then opens the map, a player types a message and walks into traffic, and a name with an accented letter breaks your database column. None of these are exotic; they come from the way keyboard input is shared between the game and the browser. This guide collects the fixes in one place.

## Autofocus that actually works

The `autofocus` attribute only works on page load, and your NUI page loaded long before the player opened the dialog. Calling `input.focus()` while the dialog is still `display: none` does nothing either. The order that works: Lua sets NUI focus, then tells the page to open; the page makes the dialog visible and focuses the field on the next frame.

*dialog.js*

```js
function openDialog(title) {
  const dialog = document.getElementById('dialog');
  const input = dialog.querySelector('input');
  dialog.querySelector('h2').textContent = title;
  input.value = '';
  dialog.classList.remove('hidden');
  requestAnimationFrame(() => input.focus());
}

window.addEventListener('message', (e) => {
  if (e.data.action === 'openDialog') openDialog(e.data.title);
});
```

*client.lua*

```lua
local function openDialog(title)
    SetNuiFocus(true, true)
    SetNuiFocusKeepInput(false)
    SendNUIMessage({ action = 'openDialog', title = title })
end
```

If focus still fails occasionally, it is usually because the page itself did not yet have keyboard focus when the element was focused. A `setTimeout(() => input.focus(), 50)` as a second attempt covers it without visible delay.

## Escape closes the dialog and opens the pause menu

This is the most reported NUI keyboard bug. The page catches `keydown` for Escape, tells Lua to close, Lua releases focus, and the game then sees the same Escape (its key-up, or a repeat) and opens the pause menu. Two changes fix it together.

1. Close on `keyup` instead of `keydown`. By the time the key is released, there is no press left for the game to see.
2. After releasing focus, disable the pause controls for a short moment in Lua.

*client.lua*

```lua
local function blockPauseBriefly(ms)
    local untilTime = GetGameTimer() + (ms or 300)
    CreateThread(function()
        while GetGameTimer() < untilTime do
            DisableControlAction(0, 199, true)
            DisableControlAction(0, 200, true)
            Wait(0)
        end
    end)
end

RegisterNuiCallback('closeDialog', function(_, cb)
    SetNuiFocus(false, false)
    blockPauseBriefly(300)
    cb({})
end)
```

*dialog.js*

```js
window.addEventListener('keyup', (e) => {
  if (e.key === 'Escape' && !document.getElementById('dialog').classList.contains('hidden')) {
    closeDialog();
  }
});
```

## Typing moves the player or opens chat

If your UI uses `SetNuiFocusKeepInput(true)` so players can walk while it is open, every letter typed into a field also reaches the game: W walks, T opens chat, number keys switch weapons. Switch keep input off whenever a text field is focused and back on when it loses focus. The complete pattern, including the Lua callback and the `focusin`/`focusout` listeners, is in [SetNuiFocusKeepInput in depth](https://fam-tools.com/guides/fivem-setnuifocuskeepinput).

> **Dialogs never need keep input** — A modal dialog asking for a plate number or a price is a moment where the player is not moving anyway. Open dialogs with keep input off, even if the parent UI (a phone, a tablet) uses it, and restore the parent’s mode when the dialog closes.

## Key repeat and shortcuts

Holding a key sends repeated `keydown` events. For typing that is correct; for shortcuts it means a held E buys ten items. Ignore repeats for anything that should fire once, and do not handle shortcuts while the player is typing in a field.

*shortcuts.js*

```js
const typing = () => {
  const el = document.activeElement;
  return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};

window.addEventListener('keydown', (e) => {
  if (e.repeat || typing()) return;
  if (e.key === 'e' || e.key === 'E') useSelectedItem();
  if (e.key === 'Delete') dropSelectedItem();
});
```

Prefer `e.key` for characters and `e.code` (for example `KeyE`) when you want the physical key regardless of keyboard layout. On AZERTY and QWERTZ keyboards, letters move; a shortcut shown as “Z” in your UI should match what the player actually presses.

## Enter, Tab and form structure

Wrap inputs in a real `<form>` and handle `submit`. You get Enter-to-submit for free, including from a number field, and one code path for both the button and the keyboard.

*dialog.html*

```html
<form id="plate-form" autocomplete="off">
  <label for="plate">Plate</label>
  <input id="plate" name="plate" maxlength="8" spellcheck="false" required />
  <button type="submit">Search</button>
  <button type="button" data-close>Cancel</button>
</form>
```

*dialog.js*

```js
document.getElementById('plate-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const plate = new FormData(e.currentTarget).get('plate').toString().trim().toUpperCase();
  if (!plate) return;
  await fetchNui('searchPlate', { plate });
});
```

`e.preventDefault()` is essential: without it the form tries to navigate, which in NUI reloads your page. `autocomplete="off"` stops the browser suggesting previous entries across players on shared machines, and `spellcheck="false"` removes red underlines from plates and codes.

Tab moves focus through every focusable element on the page, including hidden-but-focusable elements behind your dialog. Keep focus inside an open dialog by catching Tab on the last element and moving to the first, and give decorative buttons `tabindex="-1"` if they should not be reachable.

## Lengths, emoji and accented names

Players type names like “Zoë Müller” and messages with emoji. JavaScript counts UTF-16 code units, Lua’s `#` operator counts bytes and your database column counts characters, so the same string has three different lengths.

| String | JS `.length` | Lua `#` | Lua `utf8.len` |
| --- | --- | --- | --- |
| `Zoe` | 3 | 3 | 3 |
| `Zoë` | 3 | 4 | 3 |
| `Müller` | 6 | 7 | 6 |
| A single emoji such as a thumbs-up | 2 | 4 | 1 |

*server.lua — validating a character name*

```lua
local function validName(name)
    if type(name) ~= 'string' then return false end
    name = name:gsub('^%s+', ''):gsub('%s+$', '')
    local length = utf8.len(name)
    if not length or length < 2 or length > 24 then return false end
    return name:find('[%c<>]') == nil
end
```

`utf8.len` returns `nil` for invalid UTF-8, which also rejects garbage sent from devtools. `maxlength` on the input is only a convenience for honest players; the server check is the real limit. Stripping `<` and `>` keeps names from injecting HTML into other players’ UIs that render them with `innerHTML`, which you should avoid anyway in favour of `textContent`.

## Smaller edge cases

| Problem | Fix |
| --- | --- |
| Clicking outside a field leaves the game without keyboard | Focus is still on the page; that is expected while NUI focus is on. Close the UI to hand input back |
| Number input changes when scrolling the page | Blur the field on `wheel`, or use `type="text"` with `inputmode="numeric"` |
| Copy and paste in fields | Keyboard shortcuts work in text fields; for a copy button use the textarea fallback from [CEF limitations](/guides/fivem-nui-cef-limitations) |
| Chat opens when the UI closes | The T press reached the game after focus was released; close on `keyup` and briefly disable control 245 |
| Field loses focus when an update arrives | The framework re-rendered the input; keep its value in state and do not recreate the element |

When in doubt, open the [NUI devtools](https://fam-tools.com/guides/fivem-nui-devtools-debugging), run `document.activeElement` in the console and see where keyboard focus actually is.

## A reusable input dialog other scripts can await

Once the dialog works, expose it so other resources can ask the player for text without building their own. Lua promises make the call read like a normal function.

*client.lua*

```lua
local pending

local function prompt(title)
    if pending then return nil end
    pending = promise.new()
    SetNuiFocus(true, true)
    SendNUIMessage({ action = 'openDialog', title = title })
    local result = Citizen.Await(pending)
    pending = nil
    return result
end

RegisterNuiCallback('dialogResult', function(data, cb)
    SetNuiFocus(false, false)
    blockPauseBriefly(300)
    if pending then pending:resolve(data and data.value or nil) end
    cb({})
end)

exports('prompt', prompt)
```

*another resource*

```lua
local plate = exports.my_dialog:prompt('Enter a plate')
if plate then
    TriggerServerEvent('mdt:searchPlate', plate)
end
```

The page resolves with the typed value on submit and with `null` on Escape or Cancel, so callers always get an answer. The `pending` guard stops two resources from opening the dialog at once; the second caller gets `nil` and can retry. Because focus handling, the Escape fix and validation now live in one resource, every form on the server behaves the same way.

## Questions

### Why does autofocus not work in my FiveM NUI?

The attribute only runs on page load, and hidden elements cannot be focused. Show the element, then call `focus()` inside `requestAnimationFrame`.

### Why does Escape open the pause menu after closing my UI?

The game sees the same key after focus is released. Close on `keyup` and disable controls 199 and 200 for about 300 ms after closing.

### Why does my character walk while typing in NUI?

Keep input is on. Call `SetNuiFocusKeepInput(false)` while a text field has focus.

### How do I submit an NUI form with Enter?

Use a `<form>` with a submit button, handle the `submit` event and call `preventDefault()`.

### How do I limit name length correctly with accents and emoji?

Validate on the server with `utf8.len`, not `#`, because Lua’s length operator counts bytes.

## Related

- https://fam-tools.com/guides/fivem-setnuifocuskeepinput
- https://fam-tools.com/guides/fivem-nui-focus-and-cursor
- https://fam-tools.com/guides/fivem-registerkeymapping-nui
- https://fam-tools.com/guides/fivem-nui-callbacks-fetch
