Focus Events
Track when elements gain or lose focus for form validation, accessibility, and UI state management.
Search across all documentation pages
Track when elements gain or lose focus for form validation, accessibility, and UI state management.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Event | Fires When | Bubbles | Typical Elements |
|---|---|---|---|
onFocus | Element receives focus | Yes (unlike native focus) | <input>, <textarea>, <select>, <button>, <a>, any element with tabIndex |
onBlur | Element loses focus | Yes (unlike native blur) | Same as above |
onFocusCapture | Same as onFocus, but fires during capture phase | Capture | Same as above |
onBlurCapture | Same as onBlur, but fires during capture phase | Capture | Same as above |
React's
onFocusandonBlurbubble by default, matching the nativefocusin/focusoutbehavior -- not nativefocus/blurwhich do not bubble.
Quick-reference recipe card -- copy-paste ready.
// Validate on blur, highlight on focus
function ValidatedInput() {
const [error, setError] = useState<string | null>(null);
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
const value = e.currentTarget.value.trim();
setError(value.length === 0 ? "This field is required" : null);
};
return (
<div>
<input
onFocus={() => setError(null)}
onBlur={handleBlur}
className={error ? "border-red-500" : "border-gray-300"}
/>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
);
}When to reach for this: You need inline validation that runs after the user leaves a field, focus ring styling for accessibility, or tracking which element currently has focus.
// components/ValidatedEmailField.tsx
"use client";
import { useState, useRef } from "react";
type FieldState = {
value: string;
touched: boolean;
error: string | null;
};
function validateEmail(email: string): string | null {
if (email.trim().length === 0) return "Email is required";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Invalid email format";
return null;
}
export default function ValidatedEmailField() {
const [field, setField] = useState<FieldState>({
value: "",
touched: false,
error: null,
});
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleFocus: React.FocusEventHandler<HTMLInputElement> = () => {
setIsFocused(true);
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
setIsFocused(false);
const error = validateEmail(e.currentTarget.value);
setField((prev) => ({ ...prev, touched: true, error }));
};
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const value = e.currentTarget.value;
setField((prev) => ({
...prev,
value,
// Re-validate on change only if the field was already touched
error: prev.touched ? validateEmail(value) : null,
}));
};
const ringClass = isFocused
? "ring-2 ring-blue-500 border-blue-500"
: field.error
? "border-red-500"
: "border-gray-300";
return (
<form
className="max-w-sm mx-auto p-6"
onSubmit={(e) => {
e.preventDefault();
const error = validateEmail(field.value);
if (error) {
setField((prev) => ({ ...prev, touched: true, error }));
inputRef.current?.focus();
return;
}
alert(`Submitted: ${field.value}`);
}}
>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
ref={inputRef}
id="email"
type="email"
value={field.value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
aria-invalid={!!field.error}
aria-describedby={field.error ? "email-error" : undefined}
className={`w-full px-3 py-2 border rounded ${ringClass}`}
/>
{field.touched && field.error && (
<p id="email-error" role="alert" className="text-red-500 text-sm mt-1">
{field.error}
</p>
)}
<button
type="submit"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Submit
</button>
</form>
);
}What this demonstrates:
aria-invalid and aria-describedby for screen reader accessibilityinputRef.current?.focus() on submit errorfocusin/focusout events as onFocus/onBlur, which means they bubble through the React tree. This is intentional -- it lets a parent <form> or <div> listen for focus changes on any descendant.FocusEvent object includes relatedTarget, which references the element that is gaining focus (on blur) or losing focus (on focus). This lets you detect focus direction.onFocusCapture, onBlurCapture) fire before the target element's handler, useful for intercepting focus in wrapper components.Focus-within pattern (parent reacts to child focus):
function FieldGroup() {
const [hasFocusWithin, setHasFocusWithin] = useState(false);
return (
<div
onFocus={() => setHasFocusWithin(true)}
onBlur={(e) => {
// Only clear if focus is leaving the container entirely
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setHasFocusWithin(false);
}
}}
className={hasFocusWithin ? "ring-2 ring-blue-300 rounded p-4" : "p-4"}
>
<input placeholder="First name" className="block mb-2 border px-2 py-1" />
<input placeholder="Last name" className="block border px-2 py-1" />
</div>
);
}Auto-focus on mount:
function SearchModal() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Focus after paint to avoid layout thrashing
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}, []);
return <input ref={inputRef} placeholder="Search..." />;
}Focus trapping in modals:
function FocusTrap({ children }: { children: React.ReactNode }) {
const trapRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = trapRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
return (
<div ref={trapRef} onKeyDown={handleKeyDown}>
{children}
</div>
);
}Using relatedTarget to detect focus direction:
function DirectionalFocus() {
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
const leavingTo = e.relatedTarget as HTMLElement | null;
if (leavingTo?.dataset.cancel) {
// User tabbed to cancel -- discard changes
e.currentTarget.value = "";
}
};
return (
<div>
<input onBlur={handleBlur} placeholder="Type something" />
<button data-cancel="true">Cancel</button>
<button>Save</button>
</div>
);
}Blur with delay for dropdowns (prevent closing on option click):
function Dropdown() {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleFocus = () => {
clearTimeout(timeoutRef.current);
setOpen(true);
};
const handleBlur = () => {
// Delay closing so click on dropdown option can fire first
timeoutRef.current = setTimeout(() => setOpen(false), 150);
};
return (
<div onFocus={handleFocus} onBlur={handleBlur}>
<input placeholder="Search..." />
{open && (
<ul className="border rounded mt-1 shadow">
<li className="px-3 py-1 cursor-pointer hover:bg-gray-100">Option A</li>
<li className="px-3 py-1 cursor-pointer hover:bg-gray-100">Option B</li>
</ul>
)}
</div>
);
}// The generic parameter specifies the element type
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.currentTarget; // HTMLInputElement (always the element the handler is on)
e.target; // Element (could be a child that triggered the event)
};
// relatedTarget is typed as EventTarget | null
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const next = e.relatedTarget as HTMLElement | null;
// Cast is needed because relatedTarget is EventTarget | null
if (next?.tagName === "BUTTON") {
// Focus moved to a button
}
};
// Using the shorthand type alias
const onFocus: React.FocusEventHandler<HTMLSelectElement> = (e) => {
// e is React.FocusEvent<HTMLSelectElement>
};
// When listening on a parent container for any child focus
const onContainerFocus = (e: React.FocusEvent<HTMLDivElement>) => {
// e.target may be an input, button, etc. inside the div
// e.currentTarget is always the div
};onBlur fires before onClick -- If you have a dropdown that closes on blur and options that use onClick, the blur fires first and unmounts the options before the click registers. Fix: Use onMouseDown with e.preventDefault() on the option to prevent blur, or use setTimeout to delay the blur effect.
relatedTarget is null when focus moves outside the document -- When the user tabs out of the browser window or clicks on a non-focusable area, relatedTarget is null. Fix: Always check for null before accessing properties on relatedTarget.
React onFocus/onBlur bubble, but native focus/blur do not -- If you attach a native focus listener via addEventListener, it will not bubble. Mixing native and React focus listeners leads to confusing behavior. Fix: Stick to React's synthetic events consistently, or use native focusin/focusout if you must use addEventListener.
autoFocus prop causes focus before useEffect runs -- The autoFocus JSX prop focuses the element during the commit phase, before effects run. If your effect depends on knowing what is focused, it may see stale state. Fix: Use a ref callback or requestAnimationFrame inside useEffect to check focus after paint.
Calling element.focus() during render causes React warnings -- Imperatively focusing during the render phase triggers side effects. Fix: Always call .focus() inside useEffect, event handlers, or requestAnimationFrame.
Focus events fire on every child when using bubbling -- A parent onFocus handler fires every time any focusable child gains focus, not just when focus enters the parent container. Fix: Use e.currentTarget.contains(e.relatedTarget) to distinguish "focus entered the container" from "focus moved between children."
tabIndex={-1} makes elements focusable via JS but not Tab key -- Setting tabIndex={-1} allows .focus() calls but removes the element from the tab order. Setting tabIndex={0} adds it to the natural tab order. Fix: Use tabIndex={0} when you want keyboard-navigable elements, tabIndex={-1} only for programmatic focus targets.
| Alternative | Use When | Don't Use When |
|---|---|---|
CSS :focus-within | You only need visual styling changes on parent when a child is focused | You need to run JavaScript logic on focus changes |
CSS :focus-visible | You want focus rings only for keyboard users, not mouse clicks | You need to track focus state in React state |
document.activeElement | You need to check what is currently focused at a point in time | You need reactive updates when focus changes |
FocusEvent via useEffect + addEventListener | You need capture-phase focus on document or window | React synthetic events already cover your use case |
| Headless UI / Radix focus management | You need production-grade focus trapping and restoration in modals | You have a simple single-field validation scenario |
React's onFocus and onBlur bubble through the React tree, matching the behavior of native focusin/focusout. Native focus/blur events do not bubble. This means a parent element can listen for focus changes on any descendant.
onBlur, relatedTarget is the element that is gaining focusonFocus, relatedTarget is the element that is losing focusnull when focus moves outside the document (e.g., user tabs to another window)null before accessing properties on it<div
onFocus={() => setHasFocusWithin(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setHasFocusWithin(false);
}
}}
>
<input placeholder="First name" />
<input placeholder="Last name" />
</div>onBlur fires before onClick. When your dropdown closes on blur, it unmounts the options before the click event fires. Fix this by using onMouseDown with e.preventDefault() on the options to prevent blur, or use setTimeout to delay closing.
tabIndex={0} adds the element to the natural tab order, making it keyboard-navigabletabIndex={-1} makes the element focusable via JavaScript (.focus()) but removes it from the tab order0 for interactive elements users should reach via Tab; use -1 for programmatic focus targets only<input
onFocus={() => setError(null)}
onBlur={(e) => {
const value = e.currentTarget.value.trim();
setError(value.length === 0 ? "Required" : null);
}}
/>The autoFocus prop focuses the element during the commit phase, before effects run. If your useEffect checks what is focused, it may see stale state. Use a ref callback or requestAnimationFrame inside useEffect to check focus after paint.
Query all focusable elements inside the modal, then on Tab keydown redirect focus from the last element back to the first (and vice versa with Shift+Tab). Use querySelectorAll with the selector 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'.
:focus-visible when you only need visual styling (focus rings) for keyboard users, not mouse clicksonFocus/onBlur when you need to run JavaScript logic or track focus state in React state:focus-within when you only need parent styling changes on child focusBecause React's onFocus bubbles, it fires every time any focusable child gains focus. Use e.currentTarget.contains(e.relatedTarget as Node) to distinguish "focus entered the container" from "focus moved between children."
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.currentTarget; // HTMLInputElement
// relatedTarget is typed as EventTarget | null
const next = e.relatedTarget as HTMLElement | null;
if (next?.tagName === "BUTTON") { /* ... */ }
};const onFocus: React.FocusEventHandler<HTMLSelectElement> = (e) => {
// e is React.FocusEvent<HTMLSelectElement>
e.currentTarget; // HTMLSelectElement
};Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥