Focus Ring
mdc-focus-ring is a decorative focus overlay for focusable controls. It is designed to sit on top of a relatively positioned host and follow the host's shape.
Usage
Use it as a direct child when the ring belongs to the host element.
<button class="my-button">
Save
<mdc-focus-ring></mdc-focus-ring>
</button>
<style>
.my-button {
position: relative;
}
</style>
Use for when the ring should follow an external control instead of its parent.
<div class="wrap">
<mdc-focus-ring for="save-button"></mdc-focus-ring>
<button id="save-button">Save</button>
</div>
<style>
.wrap {
position: relative;
}
</style>
Use attach(control) and detach() when you need to control the target imperatively.
<div class="wrap">
<mdc-focus-ring id="ring"></mdc-focus-ring>
<button id="target">Save</button>
</div>
<style>
.wrap {
position: relative;
}
</style>
<script type="module">
import { MDCFocusRing } from '@sandlada/mdc/components/focus-ring/index'
const ring = document.querySelector('#ring')
const target = document.querySelector('#target')
ring.attach(target)
// Later, switch back to a detached state.
ring.detach()
</script>
Attributes
inward: renders the ring inside the control instead of outside. Default:false.shape-inherit: inherits the host's corner radius. Default:true.animation-disabled: disables the focus ring animation and transition. Default:false.disabled: turns the ring off. Default:false.ignore-global-config: opts the component out of global focus-ring configuration; when present the component will not inherit global focus-ring settings and will use local properties only. Default:false.focused: force-shows the focus ring (useful for host-driven forced focus or testing). Default:false.
Programmatic API:
htmlFor/for: reflects the ID of the control the ring follows.control: the resolvedHTMLElementthe ring is attached to (ornull).attach(control)/detach(): imperatively attach or detach the ring from a control. Note:attach()removes theforattribute (so the attached control is used), whiledetach()setsfor=""to create an explicit detached state (control isnull).
When a global GlobalMDCContextProvider is attached, mdc-focus-ring computes its effective disabled state like this: if GlobalMDCContext.focusRing.disabled is defined, that value is used; otherwise the component is disabled when GlobalMDCContext.enableFocusRing is false (i.e., it falls back to the inverse of enableFocusRing). A locally configured disabled value takes precedence over the global configuration. Setting ignore-global-config forces the component to ignore global settings and use local properties only.
Notes
- The control that the ring follows (the "host") should be focusable (e.g.,
tabindexnot-1). For standalone rings or when the ring is not a direct child, ensure the container usesposition: relativeso the overlay positions correctly. - Calling
detach()setsfor=""to create an intentional detached state (the ring'scontrolbecomesnull). Removing theforattribute returns control resolution to the ring's parent element. - The component is exported from
@sandlada/mdc/components/focus-ring/index, so the import shown above is the preferred entry point.
Animation Disabled
Code
<style>
.fr-anim { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-anim"><span>Animated</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-anim"><span>No animation</span><mdc-focus-ring animation-disabled></mdc-focus-ring></button>Contrast
Code
<style>
.fr-demo-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.fr-btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding-inline: 16px;
padding-block: 8px;
position: relative;
background: #e8def8;
color: #1d192b;
outline: none;
border: none;
vertical-align: top;
border-radius: 16px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.contrast-standard {
--mdc-focus-ring-color: var(--md-sys-color-secondary, #625b71);
}
.contrast-high {
--mdc-focus-ring-color: var(--md-sys-color-on-surface, #1d1b20);
--mdc-focus-ring-width: 4px;
}
.contrast-less {
--mdc-focus-ring-color: var(--mdc-focus-ring-color-reduced-contrast, #79747e);
--mdc-focus-ring-width: 2px;
}
.contrast-forced-colors {
--mdc-focus-ring-color: Highlight;
background: Canvas;
color: CanvasText;
border: 1px solid CanvasText;
}
</style>
<div class="fr-demo-container">
<button class="fr-btn contrast-standard">
<span>Standard Contrast</span>
<mdc-focus-ring persistent></mdc-focus-ring>
</button>
<button class="fr-btn contrast-high">
<span>High Contrast (More)</span>
<mdc-focus-ring persistent></mdc-focus-ring>
</button>
<button class="fr-btn contrast-less">
<span>Reduced Contrast (Less)</span>
<mdc-focus-ring persistent></mdc-focus-ring>
</button>
<button class="fr-btn contrast-forced-colors">
<span>Forced Colors (Highlight)</span>
<mdc-focus-ring persistent></mdc-focus-ring>
</button>
</div>Disabled
Code
<style>
.fr-dis { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-dis"><span>Enabled</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-dis" disabled><span>Disabled</span><mdc-focus-ring></mdc-focus-ring></button>Focused
Code
<style>
.fr-foc { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-foc"><span>Not focused</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-foc"><span>Forced focused</span><mdc-focus-ring focused></mdc-focus-ring></button>For
Code
<style>
.fr-btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
.fr-host { position: relative; display: inline-block; }
</style>
<div class="fr-host">
<button id="ext-btn-1" class="fr-btn"><span>External Control Target</span></button>
<mdc-focus-ring for="ext-btn-1"></mdc-focus-ring>
</div>Ignore Global Config
Code
<style>
.fr-cfg { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-cfg"><span>Global config</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-cfg"><span>Ignore global</span><mdc-focus-ring ignore-global-config></mdc-focus-ring></button>Inward
Code
<style>
.fr-btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-btn"><span>Outward</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-btn"><span>Inward</span><mdc-focus-ring inward></mdc-focus-ring></button>Persistent
Code
<style>
.fr-per { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
</style>
<button class="fr-per"><span>Default (transient)</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-per"><span>Persistent</span><mdc-focus-ring persistent></mdc-focus-ring></button>Shape Inherit
Code
<style>
.fr-shape { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding-inline: 12px; padding-block: 4px; position: relative; background: lightblue; color: darkblue; outline: none; border: none; vertical-align: top; margin-inline-end: 2px; margin-block-end: 2px; border-radius: 16px; }
.fr-square { border-radius: 4px; }
</style>
<button class="fr-shape"><span>Inherits rounded</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-shape fr-square"><span>Inherits square</span><mdc-focus-ring></mdc-focus-ring></button>
<button class="fr-shape"><span>Forced rounded</span><mdc-focus-ring shape-inherit="false"></mdc-focus-ring></button>