useFocusWithin is a React hook that reports whether keyboard focus is currently on a container element or any element inside it — the JavaScript equivalent of the CSS :focus-within pseudo-class. You reach for the boolean version when CSS alone is not enough: to keep a dropdown open while focus is on any of its options, to announce a form group as active, or to drive any other state from "is focus somewhere in here."
Return a [isFocusWithin, ref] pair. The caller attaches ref to the container they want to watch, and reads isFocusWithin to know whether focus is on that container or any of its descendants.
function useFocusWithin(): [
isFocusWithin: boolean, // true while focus is on the container OR any descendant
ref: React.RefObject<HTMLElement>, // attach to the container you want to watch
];
const [isFocusWithin, ref] = useFocusWithin();
// attach the ref to a container that wraps focusable children
<div ref={ref} style={{ outline: isFocusWithin ? '2px solid dodgerblue' : 'none' }}>
<input placeholder="first name" />
<input placeholder="last name" />
</div>;
focus the first input -> isFocusWithin becomes true
tab from the first to the second -> isFocusWithin stays true (no flicker)
tab out of the group entirely -> isFocusWithin becomes false
focus and blur do not bubble, so a listener on the container never hears a child gain focus. Use their bubbling counterparts, focusin and focusout.isFocusWithin true the whole time. When focus moves inside the container, the browser fires focusout on the old child before focusin on the new one.focusout, event.relatedTarget is the element focus is moving to. It is your signal for whether focus stayed inside the container.[isFocusWithin, ref] and attach the ref to the container. If the ref is never attached, the hook stays false and does nothing.We track focus for a whole container with one listener, and fix the one bug that makes a hand-rolled version flicker: the brief moment during a child-to-child focus move when it wrongly reports that focus left.
Picture a search box with a dropdown of suggestions. While the user types, or arrows through the suggestions, you want the dropdown to stay open — focus is still "in the widget." The moment focus lands somewhere else on the page, you close it. CSS :focus-within can style that, but it cannot drive JavaScript state like "is the menu open." You need the same fact as a boolean your component can read. That is useFocusWithin: attach a ref to the container, and read back a boolean that is true whenever focus is on the container or anything inside it.
To know whether focus is inside a container, you listen for focus changes on the container itself. The catch is which events. The focus and blur events fire on the exact element that gains or loses focus, and they do not bubble — they never travel up to ancestors. So a focus listener on your container hears nothing when a child input is focused: the event fired on the input and stopped there.
The fix is a different pair. focusin and focusout do the same job as focus and blur, but they bubble. Fire one on a deeply nested input and it travels up through every ancestor, including your container. That is the only reason a single listener on the container can track focus for the entire subtree.
The obvious version listens with the right events and flips a boolean — true on focusin, false on focusout:
const { useState, useRef, useEffect } = require('react');
function useFocusWithin() {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const onFocusIn = () => setIsFocusWithin(true);
const onFocusOut = () => setIsFocusWithin(false); // fires on EVERY focusout
node.addEventListener('focusin', onFocusIn);
node.addEventListener('focusout', onFocusOut);
return () => {
node.removeEventListener('focusin', onFocusIn);
node.removeEventListener('focusout', onFocusOut);
};
}, []);
return [isFocusWithin, ref];
}
It gets the hard part right: it listens with focusin/focusout, not focus/blur, so it actually hears descendants. And it passes the easy cases — focus a child and isFocusWithin is true; tab out of the container and it is false.
It breaks the instant focus moves from one child to another inside the container. Tab from the first input to the second and the browser fires focusout on the first, then focusin on the second. This handler sets false on that focusout — so for the instant before the focusin arrives, it reports not-within even though focus never actually left. In a real UI that is a visible flicker: the highlight blinks, or a dropdown wired to close on blur snaps shut mid-interaction.
You only need to change one thing: on focusout, look at where focus is going before deciding it left.
const { useState, useRef, useEffect } = require('react');
function useFocusWithin() {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return; // ref was never attached — nothing to listen on
const handleFocusIn = () => setIsFocusWithin(true);
const handleFocusOut = (event) => {
// event.relatedTarget is the element focus is moving TO. If it is still
// inside our container, focus never left — stay within. node.contains(null)
// is false, so a null relatedTarget (focus leaving the page) falls through
// to "not within", which is exactly what we want.
if (node.contains(event.relatedTarget)) return;
setIsFocusWithin(false);
};
// focus/blur do NOT bubble, so a listener here would never hear a child.
// focusin/focusout are the bubbling counterparts.
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
return () => {
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
};
}, []);
return [isFocusWithin, ref];
}
module.exports = { useFocusWithin };
The focusout event carries a relatedTarget — the element that is about to receive focus. When focus moves from the first child to the second, the focusout on the first has relatedTarget set to the second. Ask node.contains(relatedTarget): if the incoming focus target is still inside the container, focus never left, so return early and leave isFocusWithin true. Only when relatedTarget is outside — or null — do you set it false. handleFocusIn is unchanged: any focus arriving inside the container means focus is within.
The null case is handled for free. When focus leaves the window entirely — you tab away to the browser's address bar — relatedTarget is null, and node.contains(null) is false, so the guard correctly falls through and reports not-within.
Here is the exact sequence, side by side. Moving focus from child a to child b produces three events: focusin on a, then focusout on a (whose relatedTarget is b), then focusin on b.
The naive row dips to false on the middle event. The guarded row checks relatedTarget on that focusout, sees b is still inside the container, and holds true. From the user's side, focus was continuously within the container the whole time — and now the boolean agrees.
Say you wrap a name form — a first-name input a and a last-name input b — in a container that holds the ref.
isFocusWithin is false.focusin bubbles to the container; handleFocusIn runs and sets isFocusWithin true. Your outline turns on.a to b. The browser fires focusout on a with relatedTarget b, then focusin on b. On the focusout, node.contains(b) is true, so handleFocusOut returns early — isFocusWithin stays true. No flicker.focusout fires on b with relatedTarget set to that outside element; node.contains(relatedTarget) is false, so isFocusWithin becomes false. Your outline turns off.focus/blur. They do not bubble, so a listener on the container never hears a child — the hook looks completely dead. Use focusin/focusout, which do bubble. This is the single most common reason a hand-rolled focus-within does nothing.focusout. With no relatedTarget check it flickers every time focus moves between two children. Guard with node.contains(event.relatedTarget) and only report not-within when focus truly left.document.activeElement inside focusout. focusout fires before the new element is focused, so at that moment document.activeElement is still the old element (or the body). relatedTarget is the reliable signal for where focus is heading — read it, not activeElement.relatedTarget is null. node.contains(null) is false, so the containment guard already handles it — but if you invert the check without thinking about null, it is easy to get backwards and report within when focus has actually gone.useFocusWithin is shaped useFocusWithin(target, options) — you pass the element or ref in and get the boolean back, plus onFocus/onBlur/onChange callbacks. Same focusin/focusout + relatedTarget-containment core; different ergonomics.onFocusWithin and onBlurWithin options and call them on the transitions, so a caller can announce the group or run a side effect without watching the boolean in an effect.blur/focusout when the focused element is removed from the DOM. If a focused child unmounts, a listener-only hook can get stuck at true. React Aria's useFocusWithin covers this by also watching focus at the document level and synthesizing the missing blur — and it uses React's own onFocus/onBlur props, which bubble since React 17 because React implements them with focusin/focusout under the hood.ref.current. If the container element can change between renders, a callback ref re-runs the attach and detach when the node itself changes — more robust than reading ref.current a single time.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useFocusWithin is a React hook that reports whether keyboard focus is currently on a container element or any element inside it — the JavaScript equivalent of the CSS :focus-within pseudo-class. You reach for the boolean version when CSS alone is not enough: to keep a dropdown open while focus is on any of its options, to announce a form group as active, or to drive any other state from "is focus somewhere in here."
Return a [isFocusWithin, ref] pair. The caller attaches ref to the container they want to watch, and reads isFocusWithin to know whether focus is on that container or any of its descendants.
function useFocusWithin(): [
isFocusWithin: boolean, // true while focus is on the container OR any descendant
ref: React.RefObject<HTMLElement>, // attach to the container you want to watch
];
const [isFocusWithin, ref] = useFocusWithin();
// attach the ref to a container that wraps focusable children
<div ref={ref} style={{ outline: isFocusWithin ? '2px solid dodgerblue' : 'none' }}>
<input placeholder="first name" />
<input placeholder="last name" />
</div>;
focus the first input -> isFocusWithin becomes true
tab from the first to the second -> isFocusWithin stays true (no flicker)
tab out of the group entirely -> isFocusWithin becomes false
focus and blur do not bubble, so a listener on the container never hears a child gain focus. Use their bubbling counterparts, focusin and focusout.isFocusWithin true the whole time. When focus moves inside the container, the browser fires focusout on the old child before focusin on the new one.focusout, event.relatedTarget is the element focus is moving to. It is your signal for whether focus stayed inside the container.[isFocusWithin, ref] and attach the ref to the container. If the ref is never attached, the hook stays false and does nothing.We track focus for a whole container with one listener, and fix the one bug that makes a hand-rolled version flicker: the brief moment during a child-to-child focus move when it wrongly reports that focus left.
Picture a search box with a dropdown of suggestions. While the user types, or arrows through the suggestions, you want the dropdown to stay open — focus is still "in the widget." The moment focus lands somewhere else on the page, you close it. CSS :focus-within can style that, but it cannot drive JavaScript state like "is the menu open." You need the same fact as a boolean your component can read. That is useFocusWithin: attach a ref to the container, and read back a boolean that is true whenever focus is on the container or anything inside it.
To know whether focus is inside a container, you listen for focus changes on the container itself. The catch is which events. The focus and blur events fire on the exact element that gains or loses focus, and they do not bubble — they never travel up to ancestors. So a focus listener on your container hears nothing when a child input is focused: the event fired on the input and stopped there.
The fix is a different pair. focusin and focusout do the same job as focus and blur, but they bubble. Fire one on a deeply nested input and it travels up through every ancestor, including your container. That is the only reason a single listener on the container can track focus for the entire subtree.
The obvious version listens with the right events and flips a boolean — true on focusin, false on focusout:
const { useState, useRef, useEffect } = require('react');
function useFocusWithin() {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const onFocusIn = () => setIsFocusWithin(true);
const onFocusOut = () => setIsFocusWithin(false); // fires on EVERY focusout
node.addEventListener('focusin', onFocusIn);
node.addEventListener('focusout', onFocusOut);
return () => {
node.removeEventListener('focusin', onFocusIn);
node.removeEventListener('focusout', onFocusOut);
};
}, []);
return [isFocusWithin, ref];
}
It gets the hard part right: it listens with focusin/focusout, not focus/blur, so it actually hears descendants. And it passes the easy cases — focus a child and isFocusWithin is true; tab out of the container and it is false.
It breaks the instant focus moves from one child to another inside the container. Tab from the first input to the second and the browser fires focusout on the first, then focusin on the second. This handler sets false on that focusout — so for the instant before the focusin arrives, it reports not-within even though focus never actually left. In a real UI that is a visible flicker: the highlight blinks, or a dropdown wired to close on blur snaps shut mid-interaction.
You only need to change one thing: on focusout, look at where focus is going before deciding it left.
const { useState, useRef, useEffect } = require('react');
function useFocusWithin() {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return; // ref was never attached — nothing to listen on
const handleFocusIn = () => setIsFocusWithin(true);
const handleFocusOut = (event) => {
// event.relatedTarget is the element focus is moving TO. If it is still
// inside our container, focus never left — stay within. node.contains(null)
// is false, so a null relatedTarget (focus leaving the page) falls through
// to "not within", which is exactly what we want.
if (node.contains(event.relatedTarget)) return;
setIsFocusWithin(false);
};
// focus/blur do NOT bubble, so a listener here would never hear a child.
// focusin/focusout are the bubbling counterparts.
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
return () => {
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
};
}, []);
return [isFocusWithin, ref];
}
module.exports = { useFocusWithin };
The focusout event carries a relatedTarget — the element that is about to receive focus. When focus moves from the first child to the second, the focusout on the first has relatedTarget set to the second. Ask node.contains(relatedTarget): if the incoming focus target is still inside the container, focus never left, so return early and leave isFocusWithin true. Only when relatedTarget is outside — or null — do you set it false. handleFocusIn is unchanged: any focus arriving inside the container means focus is within.
The null case is handled for free. When focus leaves the window entirely — you tab away to the browser's address bar — relatedTarget is null, and node.contains(null) is false, so the guard correctly falls through and reports not-within.
Here is the exact sequence, side by side. Moving focus from child a to child b produces three events: focusin on a, then focusout on a (whose relatedTarget is b), then focusin on b.
The naive row dips to false on the middle event. The guarded row checks relatedTarget on that focusout, sees b is still inside the container, and holds true. From the user's side, focus was continuously within the container the whole time — and now the boolean agrees.
Say you wrap a name form — a first-name input a and a last-name input b — in a container that holds the ref.
isFocusWithin is false.focusin bubbles to the container; handleFocusIn runs and sets isFocusWithin true. Your outline turns on.a to b. The browser fires focusout on a with relatedTarget b, then focusin on b. On the focusout, node.contains(b) is true, so handleFocusOut returns early — isFocusWithin stays true. No flicker.focusout fires on b with relatedTarget set to that outside element; node.contains(relatedTarget) is false, so isFocusWithin becomes false. Your outline turns off.focus/blur. They do not bubble, so a listener on the container never hears a child — the hook looks completely dead. Use focusin/focusout, which do bubble. This is the single most common reason a hand-rolled focus-within does nothing.focusout. With no relatedTarget check it flickers every time focus moves between two children. Guard with node.contains(event.relatedTarget) and only report not-within when focus truly left.document.activeElement inside focusout. focusout fires before the new element is focused, so at that moment document.activeElement is still the old element (or the body). relatedTarget is the reliable signal for where focus is heading — read it, not activeElement.relatedTarget is null. node.contains(null) is false, so the containment guard already handles it — but if you invert the check without thinking about null, it is easy to get backwards and report within when focus has actually gone.useFocusWithin is shaped useFocusWithin(target, options) — you pass the element or ref in and get the boolean back, plus onFocus/onBlur/onChange callbacks. Same focusin/focusout + relatedTarget-containment core; different ergonomics.onFocusWithin and onBlurWithin options and call them on the transitions, so a caller can announce the group or run a side effect without watching the boolean in an effect.blur/focusout when the focused element is removed from the DOM. If a focused child unmounts, a listener-only hook can get stuck at true. React Aria's useFocusWithin covers this by also watching focus at the document level and synthesizing the missing blur — and it uses React's own onFocus/onBlur props, which bubble since React 17 because React implements them with focusin/focusout under the hood.ref.current. If the container element can change between renders, a callback ref re-runs the attach and detach when the node itself changes — more robust than reading ref.current a single time.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.