MDC

Focus Ring

READMEDemo

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 resolved HTMLElement the ring is attached to (or null).
  • attach(control) / detach(): imperatively attach or detach the ring from a control. Note: attach() removes the for attribute (so the attached control is used), while detach() sets for="" to create an explicit detached state (control is null).

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., tabindex not -1). For standalone rings or when the ring is not a direct child, ensure the container uses position: relative so the overlay positions correctly.
  • Calling detach() sets for="" to create an intentional detached state (the ring's control becomes null). Removing the for attribute 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>