CSS outline and focus rings that people can actually see

An outline is a line drawn around an element that takes no space. Browsers use it to show keyboard focus, and it is the safest way to draw your own focus ring.

The CSS outline property draws a line around an element without taking any space. Nothing around it moves when it appears. That makes it the natural tool for focus rings: the line that shows which button, link or field the keyboard is on.

button:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

Build one yourself. The same values go on a button and on a rounded card:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS outline builder</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage {
    display: flex; flex-wrap: wrap; gap: 28px; align-items: center; justify-content: center;
    padding: 30px 16px; background: #fff; border-radius: 12px; margin-bottom: 12px;
  }
  /* the two targets share one set of outline values */
  .target {
    outline: var(--w) var(--s) var(--c);
    outline-offset: var(--o);
  }
  .btn {
    font: 600 15px system-ui, sans-serif; padding: 10px 18px;
    border: 0; border-radius: 8px; background: #2563eb; color: #fff;
  }
  .card {
    width: 150px; padding: 14px; border-radius: var(--r);
    background: #f8fafc; border: 1px solid #d5d9e0; font-size: 13px;
  }
  .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px 14px; font-size: 13px; }
  .controls label { display: grid; gap: 3px; }
  .controls span b { font-family: ui-monospace, Consolas, monospace; }
  pre {
    margin: 12px 0 0; padding: 10px 12px; border-radius: 8px;
    background: #1d2330; color: #e5e7eb; font: 13px/1.5 ui-monospace, Consolas, monospace;
    white-space: pre-wrap;
  }
</style>
</head>
<body>
<div class="stage" id="stage">
  <button class="target btn" type="button">Button</button>
  <div class="target card">Card with<br>border-radius</div>
</div>

<div class="controls">
  <label><span>outline-width <b id="wOut"></b></span><input id="w" type="range" min="0" max="10" value="3"></label>
  <label><span>outline-offset <b id="oOut"></b></span><input id="o" type="range" min="-10" max="12" value="3"></label>
  <label><span>outline-style</span>
    <select id="s">
      <option>solid</option><option>dashed</option><option>dotted</option>
      <option>double</option><option>groove</option><option>ridge</option>
      <option>inset</option><option>outset</option><option>auto</option><option>none</option>
    </select>
  </label>
  <label><span>outline-color <b id="cOut"></b></span><input id="c" type="color" value="#f97316"></label>
  <label><span>card border-radius <b id="rOut"></b></span><input id="r" type="range" min="0" max="40" value="16"></label>
</div>

<pre id="css"></pre>

<script>
  const stage = document.getElementById('stage');
  const ids = ['w', 'o', 's', 'c', 'r'];
  const el = Object.fromEntries(ids.map((id) => [id, document.getElementById(id)]));

  function update() {
    const w = el.w.value + 'px', o = el.o.value + 'px', r = el.r.value + 'px';
    const s = el.s.value, c = el.c.value;
    // write the values into custom properties used by .target
    stage.style.setProperty('--w', w);
    stage.style.setProperty('--o', o);
    stage.style.setProperty('--s', s);
    stage.style.setProperty('--c', c);
    stage.style.setProperty('--r', r);
    document.getElementById('wOut').textContent = w;
    document.getElementById('oOut').textContent = o;
    document.getElementById('cOut').textContent = c;
    document.getElementById('rOut').textContent = r;
    document.getElementById('css').textContent =
      '.card {\n  border-radius: ' + r + ';\n  outline: ' + w + ' ' + s + ' ' + c + ';\n  outline-offset: ' + o + ';\n}';
  }

  ids.forEach((id) => el[id].addEventListener('input', update));
  update();
</script>
</body>
</html>
Width, offset, style and colour sliders. The CSS below updates as you drag.

If you are after borders themselves, CSS border covers width, style and radius. This guide is about the outline and the focus ring built from it.

The four outline properties

outline is a shorthand for three properties, and a fourth one sets the gap.

Property What it sets Example
outline-width Thickness 3px
outline-style Line type: solid, dashed, dotted, double, auto and more solid
outline-color Colour #1d4ed8
outline-offset Gap between the border edge and the outline 2px

Unlike a border, the outline is one line for the whole box. There is no outline-top or outline-left. If you need a line on one side only, use a border or a box-shadow.

The style auto asks the browser to draw its own focus ring. In Chromium, a focused field's default outline has the computed style auto.

outline-offset, including negative values

Positive offsets push the outline away from the border, which makes a focus ring read as a separate ring. Zero is the default. Negative values pull the outline inside the element.

Positive, zero and negative offset, and how outlines follow border-radius.
Positive, zero and negative offset, and how outlines follow border-radius.

In current browsers, the outline follows border-radius, offset included. We checked this in Chromium with a pill shape and a 4px offset: the outline came out as a rounded pill. Older browser versions drew a square outline around rounded boxes.

:focus vs :focus-visible

:focus matches whenever an element has focus, however it got there. A mouse click on a button gives it focus, so a :focus ring flashes on every click. That is the usual reason people reach for outline: none.

:focus-visible matches when the browser decides focus should be shown. Try both in the lab below: click the buttons, then click an empty spot and press Tab.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>:focus vs :focus-visible lab</title>
<style>
  body { margin: 0; padding: 12px 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  .row { background: #fff; border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; }
  .row h3 { margin: 0 0 8px; font: 700 13px ui-monospace, Consolas, monospace; }
  .row h3 small { font: 400 12px system-ui, sans-serif; color: #5b6270; }
  .items { display: flex; flex-wrap: wrap; gap: 10px; }
  button, input { font: inherit; padding: 7px 12px; border-radius: 7px; border: 1px solid #9aa3b2; background: #fff; color: #1d2330; }
  input { width: 120px; }

  /* A: ring on every focus, mouse click included */
  .a :focus { outline: 3px solid #f97316; outline-offset: 2px; }

  /* B: ring only when the browser decides focus should be visible */
  .b :focus-visible { outline: 3px solid #16a34a; outline-offset: 2px; }

  /* C: the trap. Focus is still there, but nothing shows it */
  .c :focus { outline: none; }

  /* D: rings drawn with box-shadow */
  .d .shadow-only:focus-visible { outline: none; box-shadow: 0 0 0 3px #7c3aed; }
  .d .shadow-plus:focus-visible { outline: 3px solid transparent; box-shadow: 0 0 0 3px #7c3aed; }

  /* rough stand-in for forced colours: shadows dropped, colours replaced */
  .sim * { box-shadow: none !important; outline-color: CanvasText !important; }
  .sim .row, .sim button, .sim input { background: Canvas; color: CanvasText; border-color: CanvasText; }

  .out { background: #1d2330; color: #e5e7eb; border-radius: 10px; padding: 9px 12px; font: 13px/1.5 ui-monospace, Consolas, monospace; min-height: 40px; }
  .out b { color: #86efac; } .out i { color: #fdba74; font-style: normal; }
  .sim-row { margin: 8px 0 0; font-size: 13px; }
  .note { margin: 4px 0 0; font-size: 12px; color: #5b6270; }
</style>
</head>
<body>
<div id="lab">
  <div class="row a"><h3>A · :focus <small>ring on click and on Tab</small></h3>
    <div class="items"><button type="button">Button</button><input placeholder="Text field"></div></div>
  <div class="row b"><h3>B · :focus-visible <small>ring on Tab, not on a button click</small></h3>
    <div class="items"><button type="button">Button</button><input placeholder="Text field"></div></div>
  <div class="row c"><h3>C · outline: none <small>focus is there, you cannot see it</small></h3>
    <div class="items"><button type="button">Button</button><input placeholder="Text field"></div></div>
  <div class="row d"><h3>D · box-shadow rings <small>try the forced colours box</small></h3>
    <div class="items"><button type="button" class="shadow-only">Shadow only</button><button type="button" class="shadow-plus">Shadow + transparent outline</button></div></div>
</div>

<div class="out" id="out">Click something, or click the page and press Tab.</div>
<label class="sim-row"><input type="checkbox" id="sim" style="width:auto"> Simulate forced colours (rough preview)</label>
<p class="note" id="note">Real forced colours come from the system setting, or DevTools, Rendering, "Emulate CSS media feature forced-colors".</p>

<script>
  const out = document.getElementById('out');

  // report which element has focus and which pseudo-classes match
  document.addEventListener('focusin', (e) => {
    const t = e.target;
    if (t.id === 'sim') return;
    const row = t.closest('.row').querySelector('h3').firstChild.textContent.trim();
    const yes = (sel) => t.matches(sel) ? '<b>yes</b>' : '<i>no</i>';
    out.innerHTML = row + ' / ' + (t.textContent || t.placeholder) +
      '<br>:focus ' + yes(':focus') + ' · :focus-visible ' + yes(':focus-visible');
  });

  document.getElementById('sim').addEventListener('change', (e) => {
    document.getElementById('lab').classList.toggle('sim', e.target.checked);
  });

  // if the system really is in forced colours mode, say so
  if (matchMedia('(forced-colors: active)').matches) {
    document.getElementById('note').textContent = 'Forced colours are on for real on this device.';
  }
</script>
</body>
</html>
Row A uses :focus, row B :focus-visible, row C removes the outline, row D draws rings with box-shadow. The dark box reports what matched.
What matched in Chromium: a clicked button gets :focus but not :focus-visible; a clicked text field gets both.
What matched in Chromium: a clicked button gets :focus but not :focus-visible; a clicked text field gets both.

Text fields are the exception. Clicking into one also matches :focus-visible, because the user is about to type there. The exact rules are up to each browser, which is the point: you describe the ring, and the browser decides when it is needed.

Never outline: none without a replacement

Row C in the lab is the trap. The button still takes focus and still responds to Enter, but nothing on screen says so. Someone using a keyboard is now navigating blind.

The rule is simple: every focusable element needs a visible focus style. If you remove the default one, put another in its place. The buttons guide shows this for each button state.

WCAG, the web accessibility guidelines, says this in success criterion 2.4.7, Focus Visible (Level AA): keyboard focus must be visible.

WCAG 2.2 adds 2.4.13, Focus Appearance (Level AAA). It asks for an indicator at least as large as a 2px-thick line around the element, with a 3:1 contrast between focused and unfocused states.

WCAG 2.2 also adds 2.4.11, Focus Not Obscured (Minimum), at Level AA: the focused element should not be entirely hidden by things like sticky headers.

Forced colours: why outline beats box-shadow

Windows contrast themes switch the browser into forced colours mode. The browser replaces your colours with a small set chosen by the user, and it sets box-shadow to none.

In forced colours, a box-shadow ring disappears. Outlines are repainted in a system colour and stay.
In forced colours, a box-shadow ring disappears. Outlines are repainted in a system colour and stay.

We checked this with Chromium's forced-colors emulation. A box-shadow ring computed to none. An outline with a custom colour was repainted in a system colour. An outline set to transparent became visible too.

That gives a well-known fix for shadow-based rings. Keep the shadow, and add a transparent outline that only shows up in forced colours:

.btn:focus-visible {
  box-shadow: 0 0 0 3px #7c3aed;
  outline: 3px solid transparent; /* visible only in forced colours */
}

The lab's forced colours box is a rough preview, not the real mode. For the real thing, open DevTools, go to Rendering, and set "Emulate CSS media feature forced-colors" to active. The box-shadow guide covers shadows in general.

A focus ring for light and dark backgrounds

A dark blue ring is easy to see on white and hard to see on navy. A white ring has the opposite problem. The fix is two colours: a dark outline with a thin white halo inside it.

:root {
  --ring: #1d4ed8;
  --ring-halo: #ffffff;
}
:is(a, button, input, [tabindex]):focus-visible {
  outline: 3px solid var(--ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 2px var(--ring-halo); /* fills the gap */
}

The shadow spreads exactly 2px, so it fills the offset gap. On a light section, the blue ring does the work. On a dark section, the white halo does. In forced colours the shadow drops out and the outline stays.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Two-colour focus ring</title>
<style>
  /* one focus style for the whole site */
  :root {
    --ring: #1d4ed8;       /* outer ring: shows on light backgrounds */
    --ring-halo: #ffffff;  /* inner halo: shows on dark backgrounds */
  }
  :is(a, button, input, [tabindex]):focus-visible,
  .show-all :is(a, button, input) {
    outline: 3px solid var(--ring);   /* stays visible in forced colours */
    outline-offset: 2px;
    box-shadow: 0 0 0 2px var(--ring-halo);  /* fills the 2px gap */
  }

  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #e9ecf1; }
  .panels { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  @media (max-width: 560px) { .panels { grid-template-columns: 1fr; } }
  .panel { border-radius: 12px; padding: 16px; }
  .light { background: #fff; color: #1d2330; }
  .dark { background: #0f172a; color: #e5e7eb; }
  .panel h3 { margin: 0 0 6px; font-size: 15px; }
  .panel p { margin: 0 0 12px; font-size: 14px; line-height: 1.5; }
  .light a { color: #1d4ed8; } .dark a { color: #93c5fd; }
  .row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
  button, input { font: inherit; font-size: 14px; border-radius: 8px; padding: 8px 14px; }
  button { border: 0; background: #2563eb; color: #fff; }
  button.ghost { background: transparent; color: inherit; border: 1px solid currentColor; }
  input { border: 1px solid #9aa3b2; background: #fff; color: #1d2330; width: 100%; box-sizing: border-box; margin-bottom: 12px; }
  .dark input { background: #1e293b; color: #e5e7eb; border-color: #475569; }
  a.card {
    display: block; text-decoration: none; color: inherit;
    padding: 12px 14px; border-radius: 12px; border: 1px solid #d5d9e0;
  }
  .dark a.card { border-color: #334155; background: #1e293b; }
  a.card b { display: block; margin-bottom: 2px; }
  a.card span { font-size: 13px; opacity: .8; }
  .toggle { display: block; margin-top: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="panels" id="panels">
  <section class="panel light">
    <h3>Light section</h3>
    <p>Press Tab to move through. Read the <a href="#guide">setup guide</a> first.</p>
    <input placeholder="Email address" aria-label="Email address">
    <div class="row"><button type="button">Subscribe</button><button type="button" class="ghost">Later</button></div>
    <a class="card" href="#pro"><b>Pro plan</b><span>The whole card is one link.</span></a>
  </section>
  <section class="panel dark">
    <h3>Dark section</h3>
    <p>Same CSS, no extra rule. See the <a href="#changelog">changelog</a>.</p>
    <input placeholder="Search" aria-label="Search">
    <div class="row"><button type="button">Search</button><button type="button" class="ghost">Clear</button></div>
    <a class="card" href="#team"><b>Team plan</b><span>The white halo carries the ring here.</span></a>
  </section>
</div>
<label class="toggle"><input type="checkbox" id="all" style="width:auto;margin:0 6px 0 0">Show every ring at once (for phones without a Tab key)</label>

<script>
  document.getElementById('all').addEventListener('change', (e) => {
    document.getElementById('panels').classList.toggle('show-all', e.target.checked);
  });
</script>
</body>
</html>
One rule for links, buttons, fields and card links, on a light and a dark section. Tick the box to see every ring on a phone.

Put the rule on :focus-visible once, in your base styles, and every focusable element picks it up. Custom controls such as a toggle switch may need the ring moved to the visible part, because the real input is hidden.

When it does not work

What you see Cause Fix
The ring is cut off at the edges A parent has overflow: hidden Use a negative outline-offset, or add padding to the parent
A ring flashes on every mouse click The rule is on :focus Move it to :focus-visible
No ring in high contrast mode The ring is a box-shadow, which forced colours removes Use outline, or add outline: 3px solid transparent
A square outline around a rounded button An older browser version Accept it, or use a box-shadow ring plus a transparent outline
The next item covers part of the ring The neighbour paints over the outline position: relative; z-index: 1 on the focused element
Keyboard users cannot see focus at all outline: none with no replacement Add a :focus-visible outline

The neighbour case shows up in button groups and tab bars. In Chromium, a 6px outline on the first of two touching flex items was covered by the second one. Giving the focused item a z-index brought the ring back on top.

Flex and grid items accept z-index as they are. Other elements also need position: relative. The z-index guide explains the stacking rules.

Focus rings are hard to review from a screenshot, because the whole point is what happens when you press Tab. Send a page that people can tab through themselves.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can click, tab and compare the rings. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between outline and border?

A border is part of the box and takes up space, so changing it can move the layout. An outline is drawn outside the border and takes no space. An outline is also the same on all four sides: there is no outline-top or outline-left.

Is outline: none bad for accessibility?

On its own, yes. It removes the only sign of where keyboard focus is. It is fine when you put a different visible focus style in its place, ideally with :focus-visible so it still shows for Tab.

What does outline-offset do?

It moves the outline away from the border edge. Positive values open a gap outside the box. Negative values pull the outline inside the box, which helps when a parent with overflow: hidden would cut it off.

Should I use :focus or :focus-visible?

Use :focus-visible for the ring. It matches when the browser decides focus should be shown, such as after pressing Tab, and usually not after a mouse click on a button. :focus matches every time, clicks included.

Why does my focus ring disappear in Windows high contrast?

In forced colours mode the browser sets box-shadow to none. A ring drawn only with box-shadow vanishes. Draw it with outline, or add outline: 2px solid transparent next to the shadow so a ring appears in that mode.

Keep reading