Text fields and keyboard handling in FiveM NUI
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.
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.
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);
});local function openDialog(title)
SetNuiFocus(true, true)
SetNuiFocusKeepInput(false)
SendNUIMessage({ action = 'openDialog', title = title })
endIf 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.
- Close on
keyupinstead ofkeydown. By the time the key is released, there is no press left for the game to see. - After releasing focus, disable the pause controls for a short moment in 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)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.
#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.
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.
<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>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 |
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
endutf8.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 |
| 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, 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.
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)local plate = exports.my_dialog:prompt('Enter a plate')
if plate then
TriggerServerEvent('mdt:searchPlate', plate)
endThe 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?
focus() inside requestAnimationFrame.Why does Escape open the pause menu after closing my UI?
keyup and disable controls 199 and 200 for about 300 ms after closing.Why does my character walk while typing in NUI?
SetNuiFocusKeepInput(false) while a text field has focus.How do I submit an NUI form with Enter?
<form> with a submit button, handle the submit event and call preventDefault().How do I limit name length correctly with accents and emoji?
utf8.len, not #, because Lua’s length operator counts bytes.Ready to pick a map?
Twelve themes on three base map styles, $8 each, instant download.
Relevant to what you just read