Indicator

Menu highlights and tab thumbs: one element that moves to whatever is hovered, pressed or selected. Works with any element, and on Liquid Blur glass.

On this page

Get started

An indicator is an element you add to a container of items. It moves to the item under the pointer, the item being pressed, the focused item, or the selected one. The items stay put; only the indicator moves, with its position, size and corners on springs that keep their speed when redirected.

  1. Install.

    pnpm add @fluidkit/indicator
  2. Add the indicator to the container, absolutely positioned and behind the items. The container needs its own positioning and stacking context (isolation: isolate, or glass, which already has one), so an indicator with z-index: -1 sits above the container’s background and below the items. Style it however you like; the script only positions 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 the pointer is down on its item */
      .menu__highlight[data-pressed] {
        background: color-mix(in srgb, currentColor 14%, transparent);
      }
    </style>
  3. Create the indicator once the elements are in the DOM. By default, the container’s children are the items, except 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();
  1. Install. The React components are included in the package (React 18+).

    pnpm add @fluidkit/indicator
  2. Add the parts. The root is the container, the thumb is the element that moves, and each item has a value. The parts handle positioning and put the thumb behind the items; the look is up to you.

    import { Indicator } from "@fluidkit/indicator/react";
    
    export function Tabs() {
      const [tab, setTab] = useState("overview");
      return (
        <Indicator.Root value={tab} onValueChange={setTab} trigger="press" className="tabs">
          <Indicator.Thumb className="tabs__thumb" />
          <Indicator.Item value="overview">Overview</Indicator.Item>
          <Indicator.Item value="releases">Releases</Indicator.Item>
        </Indicator.Root>
      );
    }
    .tabs { isolation: isolate; display: flex; padding: 3px; border-radius: 999px; }
    .tabs__thumb { background: #fff; }
    /* The selected tab, and the one under the thumb */
    .tabs [data-selected] { font-weight: 600; }

    Components are marked "use client". On the server they render plain markup.

  1. Install. The Vue components are included in the package (Vue 3.5+).

    pnpm add @fluidkit/indicator
  2. Add the parts. The root is the container, the thumb is the element that moves, and each item has a value. v-model holds the selected item. The parts handle positioning and put the thumb behind the items; the look is up to you.

    <script setup lang="ts">
    import { ref } from "vue";
    import { Indicator } from "@fluidkit/indicator/vue";
    
    const tab = ref("overview");
    </script>
    
    <template>
      <Indicator.Root v-model="tab" trigger="press" class="tabs">
        <Indicator.Thumb class="tabs__thumb" />
        <Indicator.Item value="overview">Overview</Indicator.Item>
        <Indicator.Item value="releases">Releases</Indicator.Item>
      </Indicator.Root>
    </template>
    .tabs { isolation: isolate; display: flex; padding: 3px; border-radius: 999px; }
    .tabs__thumb { background: #fff; }
    /* The selected tab, and the one under the thumb */
    .tabs [data-selected] { font-weight: 600; }

    On the server, including Nuxt, the components render plain markup.

  1. Install. The Svelte components are included in the package (Svelte 5.29+).

    pnpm add @fluidkit/indicator
  2. Add the parts. The root is the container, the thumb is the element that moves, and each item has a value. bind:value holds the selected item. The parts handle positioning and put the thumb behind the items; the look is up to you.

    <script lang="ts">
      import { Indicator } from "@fluidkit/indicator/svelte";
    
      let tab = $state("overview");
    </script>
    
    <Indicator.Root bind:value={tab} trigger="press" class="tabs">
      <Indicator.Thumb class="tabs__thumb" />
      <Indicator.Item value="overview">Overview</Indicator.Item>
      <Indicator.Item value="releases">Releases</Indicator.Item>
    </Indicator.Root>
    .tabs { isolation: isolate; display: flex; padding: 3px; border-radius: 999px; }
    .tabs__thumb { background: #fff; }
    /* The selected tab, and the one under the thumb */
    .tabs [data-selected] { font-weight: 600; }

    Put these styles in a global stylesheet or wrap them in :global(): a component’s scoped styles don’t reach the elements the parts render. On the server, including SvelteKit, the components render plain markup.

Examples

A menu

The highlight follows the mouse and gets darker while pressed (data-pressed). It also follows a finger dragged across the items; releasing on a different item than where you started clicks it. Over the separator it stays on the last item, then jumps across in a few frames with a slight motion blur, like iOS menus. It skips the disabled item.

Tabs

iOS-style tabs: the thumb sits on the selected tab (rest; in React, value; in Vue, v-model). Press and drag across the tabs and it follows the pointer; the tab you release on is selected (trigger: "press"). The code switchers on this page work the same way.

Plays, saves and listeners this week.

Triggers

trigger sets what moves the indicator. When nothing points at an item, it goes back to rest, or hides if there’s none.

auto (default)

Hover for mouse and pen, press for touch, decided per event. A device with both a trackpad and a touchscreen gets both.

hover

Follows the hovered item until the pointer leaves the container. Over a gap or separator it stays on the last item. Ignores touch.

press

Follows the pressed item, across items while the pointer is down. On touch it appears after 70ms, so starting a scroll shows nothing. Releasing on a different item than where you started clicks that item. Set click: false to turn that off.

manual

Only to() and rest move it (in React, Vue and Svelte, the selected value and active). For an indicator driven by your own state, like the current section of a page.

Except with manual, visible keyboard focus (:focus-visible) also moves it. Set focus: false to turn that off, for an indicator that only shows the selection. Arrow keys, roving focus and ARIA states are up to you; the indicator only shows where things are.

OptionsTypeScript

createIndicator(options: IndicatorOptions): Indicator

Option Description
container: HTMLElement The element containing the items. Needs its own positioning
indicator: HTMLElement Your element that moves, inside the container
items?: string Selector for the items. Default: the container’s children, except the indicator and separators (hr, [role="separator"])
trigger?: "auto" | "hover" | "press" | "manual" What moves it. Default: auto
rest?: HTMLElement | null Where it stays when nothing points at an item, like the selected tab. Default: hidden
click?: boolean With press, click the item where the pointer is released. Default: true
focus?: boolean Move to items with visible keyboard focus. Default: true, except with manual
spring?: Partial<IndicatorSprings> Springs for x, y, width, height and presence (fading out). Default: defaultIndicatorSprings
onChange?: (item) => void Called when it moves to another item, or hides (null)

The default springs are fast and don’t overshoot: the indicator covers most of the distance to the next item in three or four frames, like iOS menus. Slower springs feel like lag. A spring is { duration, bounce, settle? }, see core.

APITypeScript

Member Description
current The item it’s on or moving to. null while hidden
rest Where it stays when nothing points at an item. Update it when the selection changes
to(item, move?) Moves to item, or hides with null. { instant: true } skips the animation
update() Re-measures the items
set(settings) Updates any option except container, indicator and rest (which is a property) without recreating it. A new trigger applies immediately
destroy() Stops it, removes its listeners and restores the indicator’s inline styles

PartsReactVueSvelte

Part Renders Description
Indicator.Root div, positioned The container for the items and thumb. Takes the props below
Indicator.Thumb span aria-hidden, absolute, behind the items The element that moves
Indicator.Item button An item with a value. Has data-selected when selected and data-active while the indicator is on it

Indicator.Root props:

Prop Description
value?: string | null The selected item, for controlled use
defaultValue?: string | null The initially selected item, for uncontrolled use
onValueChange?: (value) => void Called when an item is selected by a click, or by pressing, dragging and releasing on it
active?: string | null An item pointed at by something else, like the finger on a Lift menu. null goes back to the selected item
onActiveChange?: (value) => void Called when the indicator moves to another item, or hides (null)
trigger?: "auto" | "hover" | "press" | "manual" What moves it, see Triggers. Default: auto
click?: boolean With press, click (and select) the item where the pointer is released. Default: true
focus?: boolean Move to items with visible keyboard focus. Default: true, except with manual
spring?: Partial<IndicatorSprings> Springs for x, y, width, height and presence. Default: defaultIndicatorSprings, fast and without overshoot

Controlled. Pass value and onValueChange to keep the selection in your state. Clicks then only call onValueChange.

Nesting. A root only tracks its own items, not those of a nested root, so a menu with its own indicator can sit inside tabs with another.

Changing props never recreates the indicator; a new trigger applies immediately. Callbacks always see the latest values.

Part Renders Description
Indicator.Root div, positioned The container for the items and thumb. Takes the props below
Indicator.Thumb span aria-hidden, absolute, behind the items The element that moves
Indicator.Item button An item with a value. Has data-selected when selected and data-active while the indicator is on it

Every part also takes as-child, see Composition.

Indicator.Root props and events:

Prop or event Description
v-model The selected item. Without it, the root keeps its own, starting at defaultValue
defaultValue?: string | null The initially selected item, for uncontrolled use
active?: string | null An item pointed at by something else, like the finger on a Lift menu. null goes back to the selected item
@active-change="(value) => …" The indicator moved to another item, or hid (null)
trigger?: "auto" | "hover" | "press" | "manual" What moves it, see Triggers. Default: auto
click?: boolean With press, click (and select) the item where the pointer is released. Default: true
focus?: boolean Move to items with visible keyboard focus. Default: true, except with manual
spring?: Partial<IndicatorSprings> Springs for x, y, width, height and presence. Default: defaultIndicatorSprings, fast and without overshoot

Controlled. With v-model, clicks update your ref. To accept or refuse a change yourself, pass :model-value and handle @update:model-value.

Nesting. A root only tracks its own items, not those of a nested root, so a menu with its own indicator can sit inside tabs with another.

Changing props never recreates the indicator; a new trigger applies immediately.

Part Renders Description
Indicator.Root div, positioned The container for the items and thumb. Takes the props below
Indicator.Thumb span aria-hidden, absolute, behind the items The element that moves
Indicator.Item button An item with a value. Has data-selected when selected and data-active while the indicator is on it

Every part also takes a child snippet, see Composition.

Indicator.Root props:

Prop Description
bind:value The selected item. Without it, the root keeps its own, starting at defaultValue
defaultValue?: string | null The initially selected item, for uncontrolled use
onValueChange={(value) => …} An item was selected, by a click or a press released on it
active?: string | null An item pointed at by something else, like the finger on a Lift menu. null goes back to the selected item
onActiveChange={(value) => …} The indicator moved to another item, or hid (null)
trigger?: "auto" | "hover" | "press" | "manual" What moves it, see Triggers. Default: auto
click?: boolean With press, click (and select) the item where the pointer is released. Default: true
focus?: boolean Move to items with visible keyboard focus. Default: true, except with manual
spring?: Partial<IndicatorSprings> Springs for x, y, width, height and presence. Default: defaultIndicatorSprings, fast and without overshoot

Controlled. With bind:value, clicks update your state. To accept or refuse a change yourself, bind a getter and a setter: bind:value={() => tab, (next) => allowed(next) && (tab = next)}.

Nesting. A root only tracks its own items, not those of a nested root, so a menu with its own indicator can sit inside tabs with another.

Changing props never recreates the indicator; a new trigger applies immediately.

CompositionReactVueSvelte

Render as your own element. Every part accepts render, as in Base UI: pass an element and our props are merged into it, or a function that receives the props and state ({ selected, active } for items).

<Indicator.Item value="inbox" render={<a href="/inbox" />}>Inbox</Indicator.Item>
<Indicator.Item value="sent" render={(props, { selected }) => <Tab {...props} bold={selected} />} />

With Lift. In a Lift menu, pass the item under the finger as active, and use render to make each lift item an indicator item too.

Render as your own element. Every part accepts as-child, as in Reka UI: instead of its own element, the part renders the first element in its slot, with our attributes, classes, styles, listeners and refs merged in. The slot also receives the part’s state ({ selected, active } for items).

<Indicator.Item value="inbox" as-child>
  <RouterLink to="/inbox">Inbox</RouterLink>
</Indicator.Item>
<Indicator.Item v-slot="{ selected }" value="sent">
  <b v-if="selected">Sent</b><template v-else>Sent</template>
</Indicator.Item>

With Lift. In a Lift menu, pass the item under the finger as active, and use as-child to make each lift item an indicator item too.

Render as your own element. Every part accepts a child snippet, as in bits-ui: instead of its own element, the part renders yours. Spread the props it gives onto your element: they carry our attributes, handlers and the attachment that connects it. The snippet also receives the part’s state ({ selected, active } for items).

<Indicator.Item value="inbox">
  {#snippet child({ props })}<a {...props} href="/inbox">Inbox</a>{/snippet}
</Indicator.Item>
<Indicator.Item value="sent">
  {#snippet child({ props, selected })}<button {...props}>{#if selected}<b>Sent</b>{:else}Sent{/if}</button>{/snippet}
</Indicator.Item>

With Lift. In a Lift menu, pass the item under the finger as active, and use a child snippet to make each lift item an indicator item too.

The hookReactVueSvelte

Use useIndicator(options) when the parts don’t fit your markup. It takes the same options as Indicator.Root and returns refs.

import { useIndicator } from "@fluidkit/indicator/react";

const nav = useIndicator({ value: page, onValueChange: setPage });

<nav ref={nav.container} className="tabs">
  <span ref={nav.indicator} className="thumb" />
  <a ref={nav.item("home")} href="/">Home</a>
  <a ref={nav.item("docs")} href="/docs">Docs</a>
</nav>
Member Description
container, indicator Refs for the container and the moving element
item(value) Ref for the item with this value
value, active The selected item, and the item the indicator is on now
select(value) Selects an item, like a click
engine The underlying engine, once mounted

With the hook, styling is up to you: position the container, and make the indicator absolute and behind the items, as described in How it works. The parts do this for you.

Use useIndicator(options) when the parts don’t fit your markup. Options can be an object, a ref or a getter; a getter is read again when what it uses changes. It returns function refs for :ref.

<script setup lang="ts">
import { useIndicator } from "@fluidkit/indicator/vue";

const page = ref("home");
const nav = useIndicator(() => ({ value: page.value, onValueChange: (v) => (page.value = v) }));
</script>

<template>
  <nav :ref="nav.container" class="tabs">
    <span :ref="nav.indicator" class="thumb" />
    <a :ref="nav.item('home')" href="/">Home</a>
    <a :ref="nav.item('docs')" href="/docs">Docs</a>
  </nav>
</template>
Member Description
container, indicator Function refs for the container and the moving element
item(value) Function ref for the item with this value
value, active Refs: the selected item, and the item the indicator is on now
select(value) Selects an item, like a click
engine Shallow ref to the underlying engine, once mounted

With the composable, styling is up to you: position the container, and make the indicator absolute and behind the items, as described in How it works. The parts do this for you.

Use useIndicator(() => options) when the parts don’t fit your markup. Call it in a component’s script; the options getter is read again when what it uses changes. It returns attachments for {@attach}, and state you read like any other.

<script lang="ts">
  import { useIndicator } from "@fluidkit/indicator/svelte";

  let page = $state("home");
  const nav = useIndicator(() => ({ value: page, onValueChange: (v) => (page = v) }));
</script>

<nav {@attach nav.container} class="tabs">
  <span {@attach nav.indicator} class="thumb"></span>
  <a {@attach nav.item("home")} href="/">Home</a>
  <a {@attach nav.item("docs")} href="/docs">Docs</a>
</nav>
Member Description
container, indicator Attachments for the container and the moving element
item(value) Attachment for the item with this value
value, active The selected item, and the item the indicator is on now
select(value) Selects an item, like a click
engine The underlying engine, once mounted

With the function, styling is up to you: position the container, and make the indicator absolute and behind the items, as described in How it works. The parts do this for you.

How it works

The indicator

Your own element, absolutely positioned inside the container and behind the items. While running, the script owns its inline position, size, corners, opacity, filter and visibility, and turns off its transitions (they’d lag behind the springs). It gets data-fk-part and aria-hidden. While the pointer is down on its item, it gets data-pressed; style that to show the press.

Item to item

It moves from item to item, never stopping in between. When appearing, it starts on its item. When hiding, it fades out on the item it was heading to, so sweeping the pointer past leaves it on the last item. While moving fast, it gets a slight motion blur.

Corners

It takes each item’s corner radius: a capsule button gives a capsule indicator. Items with square corners keep the indicator’s own radius. Where corner-shape is supported, that’s used too.

Measuring

Items are measured without their inline transforms, so scaled containers work (like a menu animating in with Morph). They’re re-measured when anything in the container changes size or content, and the indicator follows an item that resizes, like animated text. Nothing is measured while the pointer moves.

Disabled items

Items with :disabled or aria-disabled="true" are skipped: the indicator hides over them and doesn’t follow focus there.

Mounting

Safe to import on the server. Create it in the browser after mount, and call destroy() on unmount. With reduced motion, it moves instantly.

With Liquid Blur

The container can be glass. Glass already has positioning and its own stacking context, so an indicator with z-index: -1 sits above the glass and below the items. The indicator can be glass too, as a lens. Don’t put a glass indicator directly inside a glass group, or it will merge with the items.

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.
  • To drag a press across items on touch, give the container touch-action: none so the page doesn’t scroll.
  • If something the container can’t detect moves an item, like a transform animated on an ancestor, call update().
PlaygroundIts hard cases, live: slowed down, on other backdrops, with their boxes drawn.