Learning path · Level 5 · Tutorial 5

Manage Keyboard Focus in a Native Dialog

Implement a native modal dialog with meaningful labeling, predictable keyboard behavior, and reliable focus handling instead of recreating modal behavior with generic elements.

  • Level: Intermediate
  • Duration: 20 min
  • 7 steps
Start tutorial Back to the path
Manage Keyboard Focus in a Native Dialog

What you will go through

  1. Give the Native Dialog a Clear Name Build the semantic structure of a confirmation dialog and connect its accessible name to its visible heading. 2 min
  2. Open and Close Through Native Controls Open and dismiss the named native dialog with browser-provided button activation and modal methods. 3 min
  3. Choose a Useful Initial Focus Target Select an initial focus target that supports the task of a native modal dialog. 3 min
  4. Let the Browser Contain Keyboard Focus Verify that a native modal dialog keeps sequential document focus out of its background without a custom focus trap. 3 min
  5. Synchronize Every Dismissal Path Keep dialog state and draft content correct after Keep draft, Clear draft, or Escape. 4 min
  6. Restore Focus to a Useful Destination Record the actual opener and restore focus after rendering: return to the usable opener when possible, or continue in Draft text when confirmation disables it. 3 min
  7. Verify the Complete Keyboard Journey Run a complete local keyboard check of the native Draft workspace dialog, including cancellation, confirmation, restoration, and repeated use. 3 min

What you will learn

  • Open and close a named dialog using native buttons and dialog methods.
  • Choose an initial focus target suited to the dialog's task.
  • Keep application state consistent when the dialog closes through a button or Escape.
  • Verify keyboard focus containment and restoration without writing a custom focus trap.

Before you start

  • Keep Interface State and Rendering Consistent
  • Style Accessible Hover, Focus, and Disabled States
  • Build Accessible Forms with Native Validation

Step 1 of 7

Give the Native Dialog a Clear Name

Build the semantic structure of a confirmation dialog and connect its accessible name to its visible heading.

Recognize the Draft workspace parts

Use a native, named dialog

A native <dialog> gives this confirmation surface dialog semantics. You do not need to make a generic container imitate a dialog.

In the Draft workspace, the editable Draft text remains on the page. The Clear draft button is the opener. The dialog asks Clear this draft? and offers two actions in a deliberate order: Keep draft, then Clear draft.

A dialog also needs an accessible name. Connect the dialog to its visible heading with aria-labelledby. The attribute value must match the heading’s id.

Dialog anatomy

Diagram of the Draft workspace showing the background page, Clear draft opener, dialog heading, and dialog actions.

The visible dialog heading supplies the modal dialog’s accessible name.

Create the semantic structure

Build the local starting page

Create index.html, styles.css, and script.js in one local folder. Save the following complete contents in the matching files, then open index.html in a browser.

This version supplies the complete page structure, baseline styling, state object, and DOM references. The dialog is intentionally not opened or dismissed yet; later steps add that behavior.

index.html

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Draft workspace</title>
  <link rel="stylesheet" href="styles.css">
  <script src="script.js" defer></script>
</head>
<body>
  <main>
    <h1>Draft workspace</h1>
    <label for="draft-text">Draft text</label>
    <textarea id="draft-text" rows="6">Weekend plan</textarea>
    <button id="open-dialog" type="button">Clear draft</button>

    <dialog id="clear-dialog" aria-labelledby="dialog-title">
      <h2 id="dialog-title">Clear this draft?</h2>
      <p>This action empties the current draft.</p>
      <div class="dialog-actions">
        <button id="keep-draft" type="button">Keep draft</button>
        <button id="confirm-clear" type="button">Clear draft</button>
      </div>
    </dialog>
  </main>
</body>
</html>
Expected output
Draft workspace
Draft text
[textarea containing: Weekend plan]
[Clear draft button]

The confirmation dialog is not visible yet.

After all three files in this screen are saved in the same folder and index.html is opened, the browser shows the described page. The dialog remains closed at this stage.

styles.css

css
:root {
  font-family: system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
}

body {
  margin: 0;
}

main {
  max-width: 42rem;
  margin: 4rem auto;
  padding: 0 1.5rem;
}

label,
textarea,
button {
  display: block;
}

textarea {
  box-sizing: border-box;
  width: 100%;
  margin: 0.5rem 0 1rem;
  padding: 0.75rem;
}

button {
  padding: 0.65rem 1rem;
  font: inherit;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

button:focus-visible,
textarea:focus-visible {
  outline: 3px solid #0759c7;
  outline-offset: 3px;
}

dialog {
  max-width: 28rem;
  border: 0;
  border-radius: 0.75rem;
  box-shadow: 0 1rem 3rem #17203366;
  padding: 1.5rem;
}

dialog::backdrop {
  background: #17203399;
}

.dialog-actions {
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}
Expected output
Draft workspace
Draft text
[textarea containing: Weekend plan]
[Clear draft button]

The page has spacing and controls show a blue outline when reached with keyboard focus.

With the three-file page assembled, these styles provide the visible focus outline and the dialog’s baseline appearance. The backdrop becomes visible only when a later step opens the modal dialog.

script.js

javascript
const state = { draftText: "Weekend plan", dialogOpen: false };

let opener = null;

const draftInput = document.querySelector("#draft-text");
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#clear-dialog");
const keepButton = document.querySelector("#keep-draft");
const confirmButton = document.querySelector("#confirm-clear");

function render() {
  if (draftInput.value !== state.draftText) {
    draftInput.value = state.draftText;
  }

  openButton.disabled = state.draftText.length === 0;
}

render();
Expected output
Draft workspace
Draft text
[textarea containing: Weekend plan]
[enabled Clear draft button]

The confirmation dialog is not visible yet.

This script performs the initial render only. With the other two files saved, it keeps the textarea value and opener availability aligned with the initial state without replacing text unnecessarily.

Practice the labeling relationship

Complete the naming connection

Complete both missing attributes so the modal dialog receives its accessible name from the visible question.

html
<dialog id="clear-dialog" >
  <h2 >Clear this draft?</h2>
  <p>This action empties the current draft.</p>
</dialog>

Step 2 of 7

Open and Close Through Native Controls

Open and dismiss the named native dialog with browser-provided button activation and modal methods.

Use modality, not visibility

Open with a native modal method

Your existing Draft workspace has a named native <dialog>. To open it as a modal dialog, call showModal() on the dialog object.

showModal() requests browser-managed modality; it is more than making a box visible. show() opens a non-modal dialog. Manually adding the open attribute exposes the element, but does not perform the equivalent modal operation.

Read dialog.open to learn whether the dialog is currently open. It is a status property, not the command that opens the dialog.

Modal behavior versus visibility

Side-by-side comparison of a native modal dialog over an unavailable dimmed page and a merely visible panel over an active page.

A modal dialog is a browser-managed interaction state, not only a visible surface.

Native dialog operations

Choose a method to open the dialog. Use the property only to inspect its current state.

Opening and inspecting the dialog
OperationMeaningUse here
`dialog.showModal()`Opens as a modal dialogOpen Clear this draft?
`dialog.show()`Opens as a non-modal dialogDo not use
Set `open` attributeExposes the element manuallyDo not use
`dialog.open`Reports current open stateGuard repeated opening

Wire native activation

Let native buttons handle activation

Use one activation path: register a click listener for each native button. A browser dispatches click when someone activates a button with a pointer, Enter, or Space.

Do not add separate keydown handlers for Enter or Space. They duplicate the button’s built-in behavior and can cause an action to run twice.

The guard below avoids another opening request while the dialog is already open. Keep draft dismisses the dialog without changing the draft. The clearing action is added later.

script.js

In script.js, add this section after the existing render() function and before the final render(); call.

javascript
function handleOpen() {
  if (dialog.open) {
    return;
  }

  dialog.showModal();
}

function handleKeep() {
  dialog.close();
}

openButton.addEventListener("click", handleOpen);
keepButton.addEventListener("click", handleKeep);
Expected output
Draft workspace
Draft text: Weekend plan
Clear draft: enabled

After activating Clear draft:
Modal dialog is open: Clear this draft?

After activating Keep draft:
Modal dialog is closed
Draft text: Weekend plan

Expected browser result after saving and refreshing: the Draft workspace shows the textarea containing “Weekend plan” and an enabled Clear draft button. Select Clear draft with a pointer, Enter, or Space to open the modal dialog. Select Keep draft with a pointer, Enter, or Space to close it; the draft text remains unchanged.

Tip

One native path is enough

Keep opening and dismissal as native method calls. Do not replace showModal() with show(), set or remove the open attribute yourself, or add keyboard-specific handlers to these buttons.

Practice the dialog methods

Complete the native handlers

Complete the two handlers used by the Clear draft opener and the Keep draft dismissal button.

javascript
function handleOpen() {
  if (dialog.) {
    return;
  }

  dialog.();
}

function handleKeep() {
  dialog.();
}

Choose the complete activation path

Choose the native interaction

Which implementation correctly opens the dialog as a modal dialog while preserving one activation path for pointer, Enter, and Space?

Step 3 of 7

Choose a Useful Initial Focus Target

Select an initial focus target that supports the task of a native modal dialog.

Choose focus deliberately

Start with the safest useful action

Initial focus is where keyboard focus lands when a modal dialog opens. Choose it for the task—not because a control happens to appear first in the markup.

This dialog confirms a destructive change. Keep draft is the useful initial focus because it is the non-destructive choice. A keyboard user can press Enter to preserve the draft rather than accidentally clear it.

When you open the dialog, the visible :focus-visible outline provides evidence of the initial focus target.

Initial focus in the confirmation

A modal confirmation dialog over a dimmed draft workspace, with a visible focus outline on the non-destructive left action.

For this confirmation, initial focus supports keeping the draft rather than clearing it.

Implement the fixed target

Use autofocus for this fixed choice

Edit the existing dialog in index.html. Add autofocus only to Keep draft. This fixed dialog has one appropriate target, so the HTML attribute communicates the intent without JavaScript.

Preserve the existing heading, explanatory text, action order, and dialog-actions class.

index.html

index.html — replace only the existing dialog section with this version.

html
<dialog id="clear-dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Clear this draft?</h2>
  <p>This action empties the current draft.</p>
  <div class="dialog-actions">
    <button id="keep-draft" type="button" autofocus>Keep draft</button>
    <button id="confirm-clear" type="button">Clear draft</button>
  </div>
</dialog>
Expected output
The modal dialog opens with a visible focus outline on Keep draft.
Clear draft remains the second action.

After saving and refreshing the completed local page, open the dialog with Clear draft to observe this browser result.

Use focus() when context decides

Use an explicit focus() call only when context chooses the target. It is an alternative to a fixed autofocus target—not an additional routine call for this Draft workspace.

The request belongs immediately after showModal(), when the dialog is open. Do not add tabindex to the dialog element, and do not use positive tabindex values to manufacture a focus order.

script.js

Alternative pattern — study this pattern for a dialog whose calling code has selected an appropriate target. Do not add it alongside the autofocus used by Draft workspace.

javascript
function openDialogWithTarget(initialTarget) {
  if (dialog.open) {
    return;
  }

  dialog.showModal();
  initialTarget.focus();
}
Expected output
When this function receives an enabled control inside the dialog, the dialog opens and the visible focus outline moves to that control.

This is an alternative pattern, not a replacement for the running example's handleOpen function.

Adapt the target to the dialog

Match focus to the task

Initial focus should help the person begin the dialog's next meaningful task. The suitable target changes with the dialog's purpose.

Task-based choices

Choose a target based on what the dialog asks the person to do, rather than source order.

Appropriate initial-focus targets
Dialog taskUseful initial targetWhy
Destructive confirmationNon-destructive actionSupports the safer outcome
Short data-entry taskFirst editable fieldLets the person begin entering data
Substantial introductory contentHeading with tabindex="-1"Lets the person read the context first

Tip

Avoid artificial focus order

A heading with tabindex="-1" can receive programmatic focus but is not inserted into ordinary Tab navigation. This is useful when substantial content needs attention first; it is not needed for the short Draft workspace confirmation.

Apply the initial-focus decisions

Set the fixed initial target

Complete the attribute for the fixed initial target in this confirmation.

html
<button id="keep-draft" type="button" >Keep draft</button>

Place the context-dependent request

In the context-dependent alternative, complete the method called after opening.

javascript
function openDialogWithTarget(initialTarget) {
  if (dialog.open) {
    return;
  }

  dialog.showModal();
  initialTarget.();
}

Select targets by purpose

Match each dialog purpose to its most useful initial focus target.

Tap an item and then its matching pair.

Step 4 of 7

Let the Browser Contain Keyboard Focus

Verify that a native modal dialog keeps sequential document focus out of its background without a custom focus trap.

What a native modal changes

The background is unavailable while the dialog is modal

When showModal() opens the native modal dialog, the browser makes the rest of that document inert. The Draft text textarea and Clear draft opener remain visible in the background, but they are unavailable for normal pointer interaction and sequential keyboard navigation.

This is native modal behavior. The darkened background is only a visual cue: dialog::backdrop helps people see that the dialog is active, but it does not create focus containment.

Native modal layers

A focused modal dialog with two available buttons appears above a dimmed workspace whose textarea and opener are unavailable.

The backdrop signals the active layer; native modal behavior makes the background document inert.

Tip

Use the native behavior

Do not add inert yourself to the page background for this example. A dialog opened with showModal() already has the browser behavior needed for the background document.

Containment is not a custom trap

Follow the available focus path

Within the document, Tab and Shift+Tab can move only among controls that are available in the modal dialog. Starting from the existing initial focus on Keep draft, move forward with Tab and backward with Shift+Tab to inspect the dialog actions.

Browser chrome, such as the address bar or browser tabs, is outside the document. It is not a background page control, so document-level focus containment does not promise to keep focus away from browser chrome.

Focus containment in the document

Arrows connect only the two dialog buttons. The background textarea and opener are disconnected, while the browser toolbar is shown outside the document boundary.

Sequential navigation stays with eligible dialog controls; background controls are not eligible.

Warning

Avoid recreating the browser's focus handling

Do not intercept Tab or Shift+Tab with a keydown listener, and do not build a custom focus trap. Those additions can create incorrect keyboard behavior. The browser supplies focus containment for this native modal dialog.

Check the behavior in your browser

Run a local keyboard check

Test the local page in your browser while the draft still contains text:

  1. Activate Clear draft to open the modal dialog.
  2. Press Tab several times and watch the visible focus outline.
  3. Press Shift+Tab several times and watch it move in the reverse direction.
  4. While the dialog remains open, try to reach the background Draft text textarea using those keys.
  5. Also try selecting the dimmed background controls with the pointer.

Keep the dialog open for the check. This test is about focus containment, not dismissal behavior.

Tip

Observe focus, not just the backdrop

Use the visible :focus-visible outline as evidence of the current focused control. Do not infer focus only from the dimmed appearance of the page.

Report what the keyboard test shows

Explain the focus boundary

After your local check, write three short statements:

  1. Classify Draft text as either a background document control or browser chrome.
  2. State whether browser chrome is inside the document focus-containment guarantee.
  3. Identify whether native modal behavior or CSS background dimming prevents Tab and Shift+Tab from reaching Draft text, and report what happened in your test.

Write at least 100 characters (0/100).

Step 5 of 7

Synchronize Every Dismissal Path

Keep dialog state and draft content correct after Keep draft, Clear draft, or Escape.

Follow every dismissal path

One shared closing destination

A modal dialog can be dismissed in several ways, but completed dismissal has one shared destination: the dialog’s close listener.

When the user presses Escape, the native modal dialog first receives cancel. This confirmation does not prevent that event, so the browser performs its default dismissal and then fires close. In contrast, calling dialog.close() from Keep draft or Clear draft does not fire cancel; it proceeds to close.

Register both listeners directly on dialog because these events do not bubble. Use cancel to observe or deliberately block an Escape request—not as universal cleanup.

Dismissal lifecycle

Diagram showing Escape passing through a cancellation event before dismissal, while both dialog action buttons dismiss directly; all completed paths reach the same close listener.

Escape requests cancellation first. Button handlers call close directly; every completed dismissal reaches close.

Synchronize the resulting page

Let state follow native visibility

Treat dialog.open as authoritative for native dialog visibility. Immediately after showModal(), copy its value into state.dialogOpen. Then, in the close listener, copy dialog.open again and call render().

render() updates existing page controls. It keeps the textarea aligned with state and derives whether the opener is disabled from state.draftText.length === 0. It does not add or remove the dialog’s open attribute and never reopens the modal dialog.

While someone is editing, the input listener updates state and calls render(). The guarded textarea assignment avoids rewriting the focused textarea when its current value already matches state, preserving its caret and selection.

What changes on each path

Each path has a task-specific action, followed by the same close synchronization.

The close listener is the shared synchronization point.
PathDoes cancel fire?Draft textShared close result
Keep draft buttonNoUnchangedSynchronize and render
Clear draft buttonNoEmpty stringSynchronize and render
EscapeYesUnchangedSynchronize and render

Tip

Keep Escape available

Do not call preventDefault() in this dialog’s cancel listener. Preventing that event would block native Escape dismissal, which is not the intended behavior for this confirmation.

Implement task-specific and shared handlers

script.js

Replace the complete contents of script.js with this version. It adds confirmation, input, cancel, and close handling while preserving the native dialog lifecycle.

javascript
const state = { draftText: "Weekend plan", dialogOpen: false };

let opener = null;

const draftInput = document.querySelector("#draft-text");
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#clear-dialog");
const keepButton = document.querySelector("#keep-draft");
const confirmButton = document.querySelector("#confirm-clear");

function render() {
  if (draftInput.value !== state.draftText) {
    draftInput.value = state.draftText;
  }

  openButton.disabled = state.draftText.length === 0;
}

function handleDraftInput(event) {
  state.draftText = event.currentTarget.value;
  render();
}

function handleOpen() {
  if (dialog.open) {
    return;
  }

  dialog.showModal();
  state.dialogOpen = dialog.open;
}

function handleKeep() {
  dialog.close();
}

function handleConfirm() {
  state.draftText = "";
  dialog.close();
}

function handleCancel() {
  // Allow native Escape dismissal.
}

function handleClose() {
  state.dialogOpen = dialog.open;
  render();
}

draftInput.addEventListener("input", handleDraftInput);
openButton.addEventListener("click", handleOpen);
keepButton.addEventListener("click", handleKeep);
confirmButton.addEventListener("click", handleConfirm);
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("close", handleClose);

render();
Expected output
Draft workspace
Draft text
Weekend plan
Clear draft

Expected browser result after saving and refreshing: the page initially shows the existing draft and enabled Clear draft opener. Keep draft and Escape close the dialog without changing the draft. Clear draft empties the textarea and disables the opener.

Check the default Escape sequence

Order the Escape lifecycle

Put the native Escape lifecycle in order.

  1. The user presses Escape while the modal dialog is open.
  2. The dialog receives close; its shared handler synchronizes state and updates the page.
  3. No listener prevents the event, so the browser closes the dialog.
  4. The dialog receives its cancel event.

Apply the shared-close pattern

Complete the synchronized handlers

Complete the confirmation and shared-close handlers.

javascript
function handleConfirm() {
  state.draftText = ;
  dialog.();
}

function handleClose() {
  state.dialogOpen = dialog.;
  ();
}

Step 6 of 7

Restore Focus to a Useful Destination

Record the actual opener and restore focus after rendering: return to the usable opener when possible, or continue in Draft text when confirmation disables it.

Choose a destination intentionally

Restore focus after the page updates

Native closing ordinarily restores focus to the element focused before the modal dialog opened. That is helpful, but it does not always choose a useful destination.

A pointer can activate the opener without making it the previously focused element. Record the actual opening button from event.currentTarget before showModal().

When the dialog closes, synchronize state and call render() first. Then choose the focus destination from the resulting page: prefer the remembered opener when it is still available. After Clear draft, rendering disables that opener, so focus should move to the enabled Draft text textarea.

Focus restoration follows the resulting state

Diagram with one focus path returning to an enabled opener and another moving to a textarea after the opener becomes disabled.

Choose the destination after rendering the close result, rather than relying only on the element focused before opening.

Tip

Check whether the opener remains useful

For this page, the remembered opener is useful only when it is still in the document (isConnected) and is not disabled. In another interface, a removed or hidden opener also needs a deliberate, usable fallback—not an arbitrary first page control.

Implement the shared restoration rule

script.js

Replace the complete contents of script.js with this version. It preserves the existing dialog behavior, records the actual opener, and restores focus after the shared close listener synchronizes state and renders the updated controls.

javascript
const state = { draftText: "Weekend plan", dialogOpen: false };

let opener = null;

const draftInput = document.querySelector("#draft-text");
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#clear-dialog");
const keepButton = document.querySelector("#keep-draft");
const confirmButton = document.querySelector("#confirm-clear");

function render() {
  if (draftInput.value !== state.draftText) {
    draftInput.value = state.draftText;
  }

  openButton.disabled = state.draftText.length === 0;
}

function handleDraftInput(event) {
  state.draftText = event.currentTarget.value;
  render();
}

function handleOpen(event) {
  opener = event.currentTarget;

  if (dialog.open) {
    return;
  }

  dialog.showModal();
  state.dialogOpen = dialog.open;
}

function handleKeep() {
  dialog.close();
}

function handleConfirm() {
  state.draftText = "";
  dialog.close();
}

function handleCancel() {
  // Allow native Escape dismissal.
}

function handleClose() {
  state.dialogOpen = dialog.open;
  render();

  const focusTarget =
    opener && opener.isConnected && !opener.disabled
      ? opener
      : draftInput;

  if (document.activeElement !== focusTarget) {
    focusTarget.focus();
  }

  opener = null;
}

draftInput.addEventListener("input", handleDraftInput);
openButton.addEventListener("click", handleOpen);
keepButton.addEventListener("click", handleKeep);
confirmButton.addEventListener("click", handleConfirm);
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("close", handleClose);

render();
Expected output
Initial browser result:
Draft workspace displays Draft text containing "Weekend plan" and an enabled Clear draft opener.

After Keep draft or Escape:
The dialog closes, the draft remains "Weekend plan", and the visible focus outline is on the enabled Clear draft opener.

After Clear draft:
The dialog closes, Draft text is empty, Clear draft is disabled, and the visible focus outline is on Draft text.

This browser example renders an interface rather than terminal text. The focus outline appears when focus is moved by keyboard or by the close handler.

Why this order works

The shared handleClose() runs after every completed dismissal. Its order is intentional: render() first updates the textarea and opener availability; target selection follows that result. The guarded textarea update avoids rewriting its value when it already matches state, including during ordinary input.

The document.activeElement check avoids an unnecessary second focus() call when native focus restoration has already reached the chosen target.

Check the three outcomes

Match dismissal paths to focus destinations

After the shared close listener synchronizes state and calls render(), match each dismissal path to its expected focus destination.

Tap an item and then its matching pair.

Apply the focus fallback

Complete the restoration target

After state synchronization and rendering, complete the focus-target selection in the shared close handler.

javascript
function handleClose() {
  state.dialogOpen = dialog.open;
  render();

  const focusTarget =
    opener && opener.isConnected && !opener.disabled
      ? 
      : ;

  if (document.activeElement !== focusTarget) {
    focusTarget.focus();
  }

  opener = null;
}

Step 7 of 7

Verify the Complete Keyboard Journey

Run a complete local keyboard check of the native Draft workspace dialog, including cancellation, confirmation, restoration, and repeated use.

Create the runnable page

Run the complete local example

Use this final three-file version for the local keyboard check. Save all three files in one folder, then open index.html in your browser. The heading, visible :focus-visible outline, disabled opener, and focus destination provide observable evidence during the journey.

What to observe

Diagram of a native modal dialog over a dimmed draft workspace, with focus visibly contained between the dialog actions.

The dialog heading identifies the task, the outline identifies the current keyboard destination, and the dimmed page is not available while the modal dialog is open.

index.html

Save as index.html.

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Draft workspace</title>
  <link rel="stylesheet" href="styles.css">
  <script src="script.js" defer></script>
</head>
<body>
  <main>
    <h1>Draft workspace</h1>
    <label for="draft-text">Draft text</label>
    <textarea id="draft-text" rows="6">Weekend plan</textarea>
    <button id="open-dialog" type="button">Clear draft</button>

    <dialog id="clear-dialog" aria-labelledby="dialog-title">
      <h2 id="dialog-title">Clear this draft?</h2>
      <p>This action empties the current draft.</p>
      <div class="dialog-actions">
        <button id="keep-draft" type="button" autofocus>Keep draft</button>
        <button id="confirm-clear" type="button">Clear draft</button>
      </div>
    </dialog>
  </main>
</body>
</html>
Expected output
Draft workspace
Draft text
Weekend plan
Clear draft

After saving all three files and opening index.html, this file supplies the visible page structure. The browser result also depends on styles.css and script.js.

styles.css

Save as styles.css.

css
:root {
  font-family: system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
}

body {
  margin: 0;
}

main {
  max-width: 42rem;
  margin: 4rem auto;
  padding: 0 1.5rem;
}

label,
textarea,
button {
  display: block;
}

textarea {
  box-sizing: border-box;
  width: 100%;
  margin: 0.5rem 0 1rem;
  padding: 0.75rem;
}

button {
  padding: 0.65rem 1rem;
  font: inherit;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

button:focus-visible,
textarea:focus-visible {
  outline: 3px solid #0759c7;
  outline-offset: 3px;
}

dialog {
  max-width: 28rem;
  border: 0;
  border-radius: 0.75rem;
  box-shadow: 0 1rem 3rem #17203366;
  padding: 1.5rem;
}

dialog::backdrop {
  background: #17203399;
}

.dialog-actions {
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}
Expected output
Visible result after opening the modal dialog:
A blue focus outline appears on the focused control.
The background page is dimmed behind the dialog.

After all three files are saved, the page has a visible focus outline and the native dialog backdrop dims the background when the modal dialog opens.

Add the complete behavior

Connect behavior and restoration

Save this complete script.js beside the other two files. It keeps state.dialogOpen aligned with dialog.open, preserves the draft for Keep draft and Escape, and restores focus after the page controls have been rendered. The empty cancel listener deliberately permits native Escape dismissal.

script.js

Save as script.js. This is the final behavior file for the three-file example.

javascript
const state = { draftText: "Weekend plan", dialogOpen: false };

let opener = null;

const draftInput = document.querySelector("#draft-text");
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#clear-dialog");
const keepButton = document.querySelector("#keep-draft");
const confirmButton = document.querySelector("#confirm-clear");

function render() {
  if (draftInput.value !== state.draftText) {
    draftInput.value = state.draftText;
  }

  openButton.disabled = state.draftText.length === 0;
}

function handleDraftInput(event) {
  state.draftText = event.currentTarget.value;
  render();
}

function handleOpen(event) {
  opener = event.currentTarget;

  if (dialog.open) {
    return;
  }

  dialog.showModal();
  state.dialogOpen = dialog.open;
}

function handleKeep() {
  dialog.close();
}

function handleConfirm() {
  state.draftText = "";
  dialog.close();
}

function handleCancel() {
  // Allow native Escape dismissal.
}

function handleClose() {
  state.dialogOpen = dialog.open;
  render();

  const focusTarget =
    opener && opener.isConnected && !opener.disabled
      ? opener
      : draftInput;

  if (document.activeElement !== focusTarget) {
    focusTarget.focus();
  }

  opener = null;
}

draftInput.addEventListener("input", handleDraftInput);
openButton.addEventListener("click", handleOpen);
keepButton.addEventListener("click", handleKeep);
confirmButton.addEventListener("click", handleConfirm);
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("close", handleClose);

render();
Expected output
Draft workspace
Draft text
Weekend plan
Clear draft

After opening:
Clear this draft?
This action empties the current draft.
Keep draft receives the visible focus outline.

After Keep draft:
Weekend plan remains in Draft text.
Clear draft receives the visible focus outline.

Open index.html after saving all three files. The first four lines describe the initial page. The remaining lines are the expected result after activating Clear draft and then Keep draft.

Check every keyboard path

Use a keyboard test matrix

Test with a keyboard, not only a pointer. Use the visible dialog heading, focus outline, draft text, and opener availability as evidence. To inspect the state check without editing the example, place a breakpoint at the end of handleOpen() or handleClose() in browser DevTools and inspect state.dialogOpen and dialog.open; they should match after each listed transition.

Keyboard journey

Perform the rows in order. After confirmation, type new text into Draft text before the repeated-opening row.

Expected evidence from the local dialog
CheckKeyboard actionVisible evidenceState check
Open with EnterFocus Clear draft; press EnterThe dialog heading is visible; Keep draft has initial focustrue / true
Traverse forwardPress TabFocus moves to Clear draft inside the dialog; background is unavailabletrue / true
Traverse backwardPress Shift+TabFocus returns to Keep drafttrue / true
Keep draftPress Enter on Keep draftWeekend plan remains; focus returns to Clear draftfalse / false
EscapeReopen, then press EscapeWeekend plan remains; focus returns to Clear draftfalse / false
Confirm clearReopen; Tab to Clear draft; press SpaceTextarea is empty, opener is disabled, Draft text has focusfalse / false
RepeatType new draft text; focus Clear draft and press SpaceThe opener becomes enabled and opens the dialog predictablytrue / true

Repair, test, and review

Repair restoration and cleanup

Repair the two missing integration statements in the shared close handler.

javascript
function handleClose() {
  state.dialogOpen = dialog.open;
  render();

  const focusTarget =
    opener && opener.isConnected && !opener.disabled
      ? opener
      : draftInput;

  if (document.activeElement !== focusTarget) {
    
  }

  
}

Report your observed keyboard journey

After applying the repair, run the matrix locally. Report the focus destination after Keep draft, Escape, and Clear draft; describe the draft and opener after Clear draft; then state whether state.dialogOpen matched dialog.open after opening and after every completed dismissal. Include what happened after you typed new draft text and opened the modal dialog again.

Write at least 150 characters (0/150).

Summary

Responsibilities recap

  • A native dialog supplies dialog semantics, modality, backdrop behavior, and focus containment without a custom focus trap.
  • Application code chooses task-specific initial focus, synchronizes state in the close listener, and selects a useful restoration fallback.
  • A complete keyboard journey checks Enter, Space, Tab, Shift+Tab, Escape, confirmation, and a repeated cycle after the interface changes.
  • Keyboard testing provides useful observable evidence, but it is not a complete accessibility audit.

Complete

Congratulations! You completed: Manage Keyboard Focus in a Native Dialog

You verified a complete native dialog keyboard journey, including focus restoration, state synchronization, and repeated use.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate