Indicator

The highlight of a menu, the thumb of tabs: one element that flies onto whatever is hovered, pressed or chosen. Any element; with Liquid Blur, over glass.

On this page

Get started

An indicator is one element of your own, in a container of items. It flies onto the item under the pointer, the item a finger presses, the item with keyboard focus or the item you choose. The items don’t move; only the indicator does, its center, size and corners on springs that keep their speed when it’s sent elsewhere.

  1. Install.

    pnpm add @fluidkit/indicator
  2. Put the indicator in the container, positioned and under the items. The container needs a position of its own and a stacking context (isolation: isolate, or glass, which has one), so the indicator at z-index: -1 lies over the container’s surface and under its items. Style it as you like: the script only places it.

    <div class="menu" role="menu">
      <span class="menu__highlight" aria-hidden="true"></span>
      <button type="button" role="menuitem">Add to Library</button>
      <button type="button" role="menuitem">Love</button>
      <hr />
      <button type="button" role="menuitem">Copy Link</button>
    </div>
    
    <style>
      .menu { position: relative; isolation: isolate; display: grid; padding: 8px; }
      .menu button { min-height: 44px; border-radius: 999px; }
      .menu__highlight {
        position: absolute;
        z-index: -1;
        background: color-mix(in srgb, currentColor 8%, transparent);
      }
      /* While a pointer is down on its item */
      .menu__highlight[data-pressed] {
        background: color-mix(in srgb, currentColor 14%, transparent);
      }
    </style>
  3. Create the indicator after the elements are in the DOM. The container’s children are its items, but for the indicator itself and separators.

    import { createIndicator } from "@fluidkit/indicator";
    
    const highlight = createIndicator({
      container: document.querySelector(".menu")!,
      indicator: document.querySelector(".menu__highlight")!,
    });
    
    // On unmount:
    // highlight.destroy();

Examples

A menu

The highlight follows a mouse, and deepens while it’s pressed (data-pressed); it follows a finger dragged across the items too: released over another item than it went down on, that item is clicked. Over the separator it stays on the last item, then crosses in a few frames, blurred by its own speed, as iOS menus do. It doesn’t stop on the disabled item.

Tabs

Tabs as iOS draws them: a thumb resting on the chosen tab (rest). Pressed and dragged across the tabs it follows the pointer, and the tab it’s released on is chosen (trigger: "press"). The switches above every example’s code work the same way.

Plays, saves and listeners this week.

Triggers

trigger says what moves it. Whatever it is, when nothing points at an item the indicator goes back to rest, or away if there’s none.

auto, the default

Hover for a mouse or a pen, press for a finger, decided per event, so a tablet with a trackpad and a touchscreen gets both.

hover

The item under a hovering pointer, until the pointer leaves the container. Over a gap or a separator it stays on the last item. Touch is ignored.

press

The item under a pressing pointer, followed across items while it’s down. On touch it shows after 70ms, so a finger that lands to scroll shows nothing. Released over another item than it went down on, that item is clicked; the browser’s own click, if any, is dropped. click: false leaves clicks alone.

manual

Nothing but to() and rest: for an indicator driven by your own state, such as the current section of a page.

Unless it’s manual, keyboard focus that shows (:focus-visible) on an item moves it too; focus: false turns that off, for an indicator that shows a choice. Arrow keys, roving focus and ARIA states are yours: the indicator only shows where things are.

Options

createIndicator(options: IndicatorOptions): Indicator

Option Meaning
container: HTMLElement Where its items are; the indicator is positioned in it, so it needs a position of its own
indicator: HTMLElement Your element that moves, inside the container
items?: string The items, as a selector in the container. By default the container’s children, but for the indicator and separators (hr, [role="separator"])
trigger?: "auto" | "hover" | "press" | "manual" What moves it; auto by default
rest?: HTMLElement | null Where it stays when nothing points, such as the chosen tab. Away by default
click?: boolean With press, an item it’s released over is clicked; true by default
focus?: boolean Keyboard focus that shows moves it; true by default, unless manual
spring?: Partial<IndicatorSprings> Springs for x, y, width, height and presence (going away); anything left out uses defaultIndicatorSprings
onChange?: (item) => void Called when it heads for another item, or away (null)

The defaults are quick and don’t overshoot: most of the way from one item to the next in three or four frames, as iOS menus are. Slower reads as lagging the pointer. A spring is { duration, bounce, settle? }, as in core.

API

Member Meaning
current The item it’s on or headed for; null while away
rest Where it stays when nothing points. Set it when the choice changes, and it goes there unless something points at it now
to(item, move?) Onto item, or away with null. { instant: true } puts it there at once
update() Measures the items again and follows them
destroy() Stops it, removes its listeners and gives the indicator its own inline styles back. Later calls do nothing

Layout

The indicator

Your own element, absolutely positioned in the container and under the items. While it runs, its inline position, size, corners, opacity, filter and visibility are the script’s, and its own transitions are off: they’d lag behind the springs. It’s marked data-fk-part and aria-hidden, so press behaviors don’t press it and a morph doesn’t count it as a neighbor. While a pointer is down on its item (a press, or a click on the hovered one) it’s marked data-pressed: style that, a deeper shade, to show the press.

Item to item

It goes from item to item, never in between. Coming, it’s on its item at once; going away, it fades as it lands on the item it was headed for, so a pointer swept past leaves it on the last item it was over. On the way it’s blurred and a little fainter by its own speed.

Corners

Each item lends it its corners, circular and fitted as the browser draws them: a capsule button makes a capsule indicator. An item with square corners lends it the indicator’s own. Where corner-shape is supported, the shape comes along.

Measuring

Items are measured at rest, without their own inline transforms, in the indicator’s own pixels, so a container that’s scaled (a menu flying in through a morph) works. They’re measured again when anything in the container is resized or changed. A pointer is hit-tested against those boxes: nothing is laid out while it moves.

Disabled items

An item that’s :disabled or aria-disabled="true" isn’t pointed at: over it the indicator goes away, and it doesn’t take focus there.

Mounting

Can be imported on the server. Create it in the browser after mounting or hydration, and call destroy() on unmount. With reduced motion it moves at once.

With Liquid Blur

The container can be glass: glass is positioned and a stacking context of its own, so the indicator at z-index: -1 lies over the glass and under the items. The indicator can be glass too, as a lens. Inside a glass group it would melt with the items, as any glass child does; keep a glass indicator out of a group’s direct children.

Browser support

Any browser with ES2022, ResizeObserver and MutationObserver. Corner shapes follow the items where corner-shape is supported; elsewhere corners stay round.

Limitations

  • Rotated ancestors aren’t supported; scaled ones are.
  • For a press to drag across items, give the container touch-action: none (a finger doesn’t scroll the page) and user-select: none (a mouse doesn’t select text). A press the browser takes over all the same, such as a drag of something selected, ends without a choice.
  • An item moved by something the container doesn’t see, such as a transform animated on an ancestor, needs update().