Focus
Keyboard focus always shows. A field lights up its own border; every other control gets a ring around it.
<tk-button>Save</tk-button> <tk-checkbox>Remember me</tk-checkbox> <tk-switch>Notifications</tk-switch>
Press Tab to move through the examples on this page. Focus shows for the keyboard
(:focus-visible), not after a mouse click.
Fields: the border
A field that takes text, such as an input, select or date picker, has no ring. On focus its border turns to the focus color and thickens 0.5px inward, so nothing on the page moves.
<tk-field label="Email address"> <tk-input type="email" name="email"></tk-input> </tk-field> <tk-field label="Promo code" error="This code has expired"> <tk-input name="promo" value="SPRING"></tk-input> </tk-field>
| State | Border |
|---|---|
| rest | border/default, 1px |
| hover | border/strong, 1px |
| focus | border/accent/focus, 1.5px |
| error | border/error, 1px |
| error + focus | border/error, 1.5px |
| read-only | border/subtle, 1px; still shows focus |
| disabled | border/disabled, 1px; can't take focus |
- An error stays red on focus. The border thickens, so the user sees where they are typing and what is wrong.
- Focus wins over hover. A focused field keeps its focus border under the pointer.
Everything else: the ring
Buttons, links, checkboxes, tabs, sliders, cards and every other control get a 2px ring, 2px away from their edge. The gap lets the page show through, so the ring stays visible on a filled control.
<tk-button variant="primary">Publish</tk-button> <tk-button tone="danger">Delete</tk-button> <tk-icon-button label="Settings"><tk-icon name="settings"></tk-icon></tk-icon-button>
The ring is the same color on every variant and tone: a danger button's ring isn't red. It says where the keyboard is, not what the control means.
Focus on your own controls
Use the tokens and the native outline:
.my-control:focus-visible {
outline: var(--focus-ring-width) solid var(--border-accent-focus);
outline-offset: var(--focus-ring-offset);
}- Use
:focus-visible, not:focus. One exception: the active item of a menu or list under roving focus may use:focusfor its highlight, since it must show after a click too. - Never remove an outline without a replacement.
- Use the tokens, not
2px, so your controls match.
The ring's corner
An outline follows the element's border-radius by itself, so don't set a radius on a
ring. Its corner is the control's radius plus the 2px offset: an 8px control rings at 10px,
a 12px card at 14px. If you must draw a ring with box-shadow, set that radius yourself:
calc(var(--radius-8) + var(--focus-ring-offset)).
The inset ring
Draw the ring inside the control when an outer ring won't fit:
- a thin handle, where the ring would be bigger than the target;
- a scroll region, a table or a code block, whose edge is the component's edge, so a
parent with
overflowwould clip an outer ring; - a cell in a dense grid, such as a calendar day, where it would overlap its neighbors;
- a small button inside a field, where an outer ring would trace the field's border.
.my-handle:focus-visible {
outline: var(--focus-ring-width) solid var(--border-accent-focus);
outline-offset: calc(var(--focus-ring-offset) * -1);
}Negate the offset token, not the width token. An inset ring's corner is the control's own.
Rings that get covered
An outline is painted with its element, and two things can erase it:
- a positioned child (
sticky,absolute, evenrelative), such as a sticky header, paints over the ring; - a
mask,opacityorfilteron the element fades the ring with it.
The fix: draw the ring on an empty sibling that covers the region, and make the region a stacking context.
.region { position: relative; }
.region > .scroll { isolation: isolate; }
.region > .ring { position: absolute; inset: 0; pointer-events: none; }
.scroll:focus-visible { outline: none; }
.scroll:focus-visible ~ .ring {
outline: var(--focus-ring-width) solid var(--border-accent-focus);
outline-offset: calc(var(--focus-ring-offset) * -1);
}Thykes scroll regions (Scroll area, Table, Data table, Code block, Sidebar) already work this way.
Inverse surfaces
On an inverse surface (surface/inverse: dark in the light theme, light in the dark one),
the focus color is too faint to see. Point the focus token at border/inverse on the surface, and every control inside it,
including ones you slot in, rings in the inverse color:
.my-inverse-panel { --border-accent-focus: var(--border-inverse); }Toasts and loud banners already do this, and tk-link takes color="inverse".
<tk-banner treatment="loud" label="Update" dismissible> A new version is available. <tk-button slot="actions" size="small">Reload</tk-button> </tk-banner>
Forced colors
In Windows High Contrast Mode (forced-colors: active) the browser removes box-shadow and
paints every border in one system color, so a field's own focus border disappears. Thykes
fields add a real outline there, in the system Highlight color. Rings are outlines already
and need nothing. If you build your own field, do the same:
@media (forced-colors: active) {
.my-field:focus-within {
outline: var(--focus-ring-width) solid Highlight;
outline-offset: var(--focus-ring-offset);
}
}Accessibility
Built in:
- A visible focus indicator on every focusable part, for keyboard users.
- Focus never moves the layout: the field border thickens inward, and an outline takes no space.
- An indicator that survives Windows High Contrast Mode.
- Read-only fields take focus and show it; disabled ones don't take focus.
Your part:
- Keep the indicator on anything you style or build; never
outline: nonealone. - On an inverse surface you build, point
--border-accent-focusatborder/inverse. - Don't let a sticky child, mask or
overflowhide a ring; check it with the keyboard.
Contrast limits
The ring uses the primary color (primary/500 in light and dark, primary/800 in high
contrast), so its contrast depends on the primary you pick. The non-text minimum is 3:1.
- Light: blue, indigo and purple pass on every surface. Orange fails on every surface
(2.20:1 on white). Gray fails on
surface/subtle(2.9993:1) andsurface/sunken; green, mint, teal and brown fail onsurface/sunken. - Dark: only orange passes everywhere. Blue is 2.38:1 on
surface/subtleand 3.32:1 onsurface/raised, the panel of every dialog, menu and popover. Indigo fails on both. - High contrast: passes for every hue on every surface.
If focus must be clear for everyone, offer the high-contrast theme.
Do / Don't
| ✅ Do | ❌ Don't |
|---|---|
| Let a field's border show its focus | Add a ring and a border change to a field |
Use outline with the focus tokens |
Write outline: 2px solid … |
| Keep the error red on a focused invalid field | Let the focus color replace the error color |
Use :focus-visible |
Use bare :focus for a ring |
Negate --focus-ring-offset for an inset ring |
Negate --focus-ring-width |
| Draw the ring on a sibling overlay when a child is positioned | Draw it on the element that contains the sticky child |
Point the focus token at border/inverse on an inverse surface |
Leave a primary ring on an inverse surface |
Three lengths draw every focus indicator; one color token paints it.
Geometry
The same in every theme.
| Token | CSS variable | Value | Use for |
|---|---|---|---|
focus-ring-width |
--focus-ring-width |
2px | the ring's thickness (outline-width) |
focus-ring-offset |
--focus-ring-offset |
2px | the gap between the control and its ring (outline-offset); negate it for an inset ring |
focus-border-inset |
--focus-border-inset |
0.5px | how far a focused field's border thickens inward |
Color
| Token | Light | Dark | High contrast | Use for |
|---|---|---|---|---|
border/accent/focus |
primary/500 |
primary/500 |
primary/800 |
the ring, and a focused field's border |
border/inverse |
gray-50 |
gray-950 |
gray-50 |
the ring on an inverse surface (surface/inverse) |
border/accent/focus follows the primary color you pick. Its contrast limits are in
How they combine
A ring:
.my-control:focus-visible {
outline: var(--focus-ring-width) solid var(--border-accent-focus);
outline-offset: var(--focus-ring-offset);
}A focused field:
.my-field:focus-within {
border-color: var(--border-accent-focus);
box-shadow: inset 0 0 0 var(--focus-border-inset) var(--border-accent-focus);
}