logo dile-components dile-components

DileHighlightMixin

Use this mixin when you need to draw attention to a specific component or nested element after a validation error, a navigation jump, or another user action.

It highlights the target with a temporary glow and can scroll it into view with smooth motion, while respecting the browser's reduced-motion preference. The glow is added on top of any box-shadow the target already has, so existing shadows are preserved.

Installation

npm i @dile/ui

Usage

Import and extend your custom element with the mixin:

import { LitElement, html } from 'lit';
import { DileHighlightMixin } from '@dile/ui/mixins/highlight';

class MyAlert extends DileHighlightMixin(LitElement) {
  render() {
    return html`
      <button type="button" @click=${() => this.highlight()}>
        Highlight me
      </button>
    `;
  }
}

If you want to highlight a nested element instead of the host, override the highlightTarget getter:

get highlightTarget() {
  return this.shadowRoot.querySelector('.focus-target');
}

highlight() waits for the component's updateComplete before reading highlightTarget, so you can call it right after changing a property that renders the target:

async requestUpload() {
  this.showUploader = true;
  await this.highlight();
}

Methods

When scrolling smoothly to a target that is not fully visible, the animation starts once scrolling ends (scrollend event, or after about 800 ms in browsers without it). Calling highlight() again restarts the animation instead of stacking it, and the animation is cancelled when the component is removed from the page.

const animation = await this.highlight({ scroll: { block: 'start' } });
await animation?.finished;

Reduced motion

When the user has prefers-reduced-motion: reduce enabled, scrolling is always instant (behavior: 'auto'), even if you pass behavior: 'smooth'. The highlight is still shown, because it is a color change rather than motion, but as a static glow that appears, stays and disappears without pulsing.

CSS Custom Properties

Custom property Description Default
--dile-highlight-color Color used by the glow. currentColor
--dile-highlight-size Thickness of the glow. 4px
--dile-highlight-duration Duration of the animation. Accepts 1200, 1200ms or 1.2s. Invalid or non-positive values fall back to the default. 1200

These properties are read from the highlighted element, which inherits them from the component, so you can set them on the host or directly on the target.

DileHighlightMixin demos

Basic usage

<script type="module">
  import { LitElement, html, css } from 'lit';
  import { DileHighlightMixin } from '@dile/ui/mixins/highlight/index.js';

  class HighlightDemo extends DileHighlightMixin(LitElement) {
    static styles = css`
      :host {
        display: block;
        --dile-highlight-color: #2f6fed;
        --dile-highlight-duration: 1400;
      }

      .card {
        padding: 1rem 1.25rem;
        border: 1px solid #d0d7de;
        border-radius: 12px;
        background: white;
      }

      .focus-target {
        border: 1px solid #b9d1ff;
        border-radius: 10px;
        padding: 0.8rem 1rem;
        background: #f8fbff;
        font-weight: 600;
      }

      button {
        margin-top: 1rem;
      }
    `;

    get highlightTarget() {
      return this.shadowRoot.querySelector('.focus-target');
    }

    render() {
      return html`
        <div class="card">
          <div class="focus-target">This element receives the glow</div>
          <button type="button" @click=${() => this.highlight()}>
            Highlight target
          </button>
        </div>
      `;
    }
  }

  customElements.define('dile-highlight-demo-card', HighlightDemo);
</script>
<dile-highlight-demo-card></dile-highlight-demo-card>

Without scrolling

<script type="module">
  import { LitElement, html, css } from 'lit';
  import { DileHighlightMixin } from '@dile/ui/mixins/highlight/index.js';

  class HighlightNoScrollDemo extends DileHighlightMixin(LitElement) {
    static styles = css`
      :host {
        display: block;
        padding: 1rem;
        border: 1px solid #cdd5df;
        border-radius: 10px;
      }
    `;

    render() {
      return html`
        <div class="box">
          <button type="button" @click=${() => this.highlight({ scroll: false })}>
            Highlight without scrolling
          </button>
        </div>
      `;
    }
  }

  customElements.define('dile-highlight-no-scroll-demo', HighlightNoScrollDemo);
</script>
<dile-highlight-no-scroll-demo></dile-highlight-no-scroll-demo>

Element with an existing shadow

<script type="module">
  import { LitElement, html, css } from 'lit';
  import { DileHighlightMixin } from '@dile/ui/mixins/highlight/index.js';

  class HighlightShadowDemo extends DileHighlightMixin(LitElement) {
    static styles = css`
      :host {
        display: block;
        padding: 1.25rem;
        border-radius: 14px;
        background: white;
        box-shadow: 0 8px 18px rgba(15, 23, 42, 0.15);
        --dile-highlight-color: #2f6fed;
        --dile-highlight-size: 3px;
        --dile-highlight-duration: 1.4s;
      }
    `;

    render() {
      return html`
        <p>This card keeps its own shadow while the glow is shown.</p>
        <button type="button" @click=${() => this.highlight({ scroll: false })}>
          Highlight card
        </button>
      `;
    }
  }

  customElements.define('dile-highlight-shadow-demo', HighlightShadowDemo);
</script>
<dile-highlight-shadow-demo></dile-highlight-shadow-demo>

Target rendered just before highlighting

<script type="module">
  import { LitElement, html, css } from 'lit';
  import { DileHighlightMixin } from '@dile/ui/mixins/highlight/index.js';

  class HighlightLateTargetDemo extends DileHighlightMixin(LitElement) {
    static properties = {
      showPanel: { type: Boolean },
    };

    static styles = css`
      :host {
        display: block;
        --dile-highlight-color: #e5484d;
      }

      .panel {
        margin-top: 1rem;
        padding: 0.8rem 1rem;
        border: 1px solid #f3b4b6;
        border-radius: 10px;
        background: #fff7f7;
      }
    `;

    constructor() {
      super();
      this.showPanel = false;
    }

    get highlightTarget() {
      return this.shadowRoot.querySelector('.panel');
    }

    async reveal() {
      this.showPanel = true;
      // highlight() waits for the render, so the panel already exists here.
      const animation = await this.highlight();
      await animation?.finished;
    }

    render() {
      return html`
        <button type="button" @click=${this.reveal}>Show and highlight panel</button>
        ${this.showPanel ? html`<div class="panel">I was rendered right before the highlight.</div>` : ''}
      `;
    }
  }

  customElements.define('dile-highlight-late-target-demo', HighlightLateTargetDemo);
</script>
<dile-highlight-late-target-demo></dile-highlight-late-target-demo>