auto (default)Hover for mouse and pen, press for touch, decided per event. A device with both a trackpad and a touchscreen gets both.
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.
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.
Install.
pnpm add @fluidkit/indicatorAdd 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>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();Install. The React components are included in the package (React 18+).
pnpm add @fluidkit/indicatorAdd 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.
Install. The Vue components are included in the package (Vue 3.5+).
pnpm add @fluidkit/indicatorAdd 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.
Install. The Svelte components are included in the package (Svelte 5.29+).
pnpm add @fluidkit/indicatorAdd 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.
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.
<div class="menu lb" role="menu" aria-label="Album">
<span class="highlight" aria-hidden="true"></span>
<button type="button" role="menuitem"><svg>…</svg>Add to Library</button>
<button type="button" role="menuitem"><svg>…</svg>Love</button>
<button type="button" role="menuitem"><svg>…</svg>Share Album</button>
<hr />
<button type="button" role="menuitem"><svg>…</svg>Copy Link</button>
<button type="button" role="menuitem" aria-disabled="true"><svg>…</svg>Show in Store</button>
</div>import "@fluidkit/blur/style.css";
import { createIndicator } from "@fluidkit/indicator";
createIndicator({
container: document.querySelector(".menu")!,
indicator: document.querySelector(".highlight")!,
});import "@fluidkit/blur/style.css";
import { Indicator } from "@fluidkit/indicator/react";
// Default trigger: hover for mouse, press and drag for touch
export function AlbumMenu() {
return (
<Indicator.Root className="menu lb" role="menu" aria-label="Album">
<Indicator.Thumb className="highlight" />
<Indicator.Item value="library" role="menuitem"><PlusIcon />Add to Library</Indicator.Item>
<Indicator.Item value="love" role="menuitem"><HeartIcon />Love</Indicator.Item>
<Indicator.Item value="share" role="menuitem"><ArrowIcon />Share Album</Indicator.Item>
<hr />
<Indicator.Item value="link" role="menuitem"><CopyIcon />Copy Link</Indicator.Item>
<Indicator.Item value="store" role="menuitem" aria-disabled><EyeIcon />Show in Store</Indicator.Item>
</Indicator.Root>
);
}<script setup lang="ts">
import "@fluidkit/blur/style.css";
import { Indicator } from "@fluidkit/indicator/vue";
</script>
<!-- Default trigger: hover for mouse, press and drag for touch -->
<template>
<Indicator.Root class="menu lb" role="menu" aria-label="Album">
<Indicator.Thumb class="highlight" />
<Indicator.Item value="library" role="menuitem"><PlusIcon />Add to Library</Indicator.Item>
<Indicator.Item value="love" role="menuitem"><HeartIcon />Love</Indicator.Item>
<Indicator.Item value="share" role="menuitem"><ArrowIcon />Share Album</Indicator.Item>
<hr />
<Indicator.Item value="link" role="menuitem"><CopyIcon />Copy Link</Indicator.Item>
<Indicator.Item value="store" role="menuitem" aria-disabled="true"><EyeIcon />Show in Store</Indicator.Item>
</Indicator.Root>
</template><script lang="ts">
import "@fluidkit/blur/style.css";
import { Indicator } from "@fluidkit/indicator/svelte";
</script>
<!-- Default trigger: hover for mouse, press and drag for touch -->
<Indicator.Root class="menu lb" role="menu" aria-label="Album">
<Indicator.Thumb class="highlight" />
<Indicator.Item value="library" role="menuitem"><PlusIcon />Add to Library</Indicator.Item>
<Indicator.Item value="love" role="menuitem"><HeartIcon />Love</Indicator.Item>
<Indicator.Item value="share" role="menuitem"><ArrowIcon />Share Album</Indicator.Item>
<hr />
<Indicator.Item value="link" role="menuitem"><CopyIcon />Copy Link</Indicator.Item>
<Indicator.Item value="store" role="menuitem" aria-disabled="true"><EyeIcon />Show in Store</Indicator.Item>
</Indicator.Root>/* Glass is already positioned and has its own stacking context, so no `isolation` needed */
.menu {
position: relative;
width: 248px;
display: grid;
padding: 8px;
border-radius: 32px;
/* Dragging a press across the items shouldn't scroll the page or select text */
touch-action: none;
user-select: none;
}
.menu button {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 18px 0 16px;
border-radius: 999px;
}
.menu button[aria-disabled="true"] { opacity: 0.4; }
.menu hr { margin: 4px 12px; border: 0; border-top: 1px solid rgb(127 127 127 / 0.25); }
.highlight {
position: absolute;
z-index: -1;
background: color-mix(in srgb, currentColor 8%, transparent);
}
/* While the pointer is down on its item */
.highlight[data-pressed] {
background: color-mix(in srgb, currentColor 14%, transparent);
}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.
<div class="tabs" role="tablist" aria-label="Artist">
<span class="tabs__thumb" aria-hidden="true"></span>
<button type="button" role="tab" aria-selected="true">Overview</button>
<button type="button" role="tab" aria-selected="false">Releases</button>
<button type="button" role="tab" aria-selected="false">Audience</button>
</div>import { createIndicator } from "@fluidkit/indicator";
const list = document.querySelector<HTMLElement>(".tabs")!;
const tabs = [...list.querySelectorAll<HTMLElement>("[role='tab']")];
const thumb = createIndicator({
container: list,
indicator: list.querySelector(".tabs__thumb")!,
trigger: "press",
rest: tabs.find((tab) => tab.getAttribute("aria-selected") === "true")!,
// Shows the selection only; keyboard focus keeps its own ring
focus: false,
});
for (const tab of tabs) {
tab.addEventListener("click", () => {
for (const t of tabs) t.setAttribute("aria-selected", String(t === tab));
thumb.rest = tab;
});
}import { useState } from "react";
import { Indicator } from "@fluidkit/indicator/react";
const tabs = { overview: "Overview", releases: "Releases", audience: "Audience" };
export function ArtistTabs() {
const [tab, setTab] = useState("overview");
return (
// Shows the selection only; keyboard focus keeps its own ring
<Indicator.Root value={tab} onValueChange={setTab} trigger="press" focus={false} className="tabs" role="tablist" aria-label="Artist">
<Indicator.Thumb className="tabs__thumb" />
{Object.entries(tabs).map(([value, label]) => (
<Indicator.Item key={value} value={value} role="tab" aria-selected={tab === value}>
{label}
</Indicator.Item>
))}
</Indicator.Root>
);
}<script setup lang="ts">
import { ref } from "vue";
import { Indicator } from "@fluidkit/indicator/vue";
const tabs = { overview: "Overview", releases: "Releases", audience: "Audience" };
const tab = ref("overview");
</script>
<template>
<!-- Shows the selection only; keyboard focus keeps its own ring -->
<Indicator.Root v-model="tab" trigger="press" :focus="false" class="tabs" role="tablist" aria-label="Artist">
<Indicator.Thumb class="tabs__thumb" />
<Indicator.Item v-for="(label, value) in tabs" :key="value" :value="value" role="tab" :aria-selected="tab === value">
{{ label }}
</Indicator.Item>
</Indicator.Root>
</template><script lang="ts">
import { Indicator } from "@fluidkit/indicator/svelte";
const tabs = { overview: "Overview", releases: "Releases", audience: "Audience" };
let tab = $state("overview");
</script>
<!-- Shows the selection only; keyboard focus keeps its own ring -->
<Indicator.Root bind:value={tab} trigger="press" focus={false} class="tabs" role="tablist" aria-label="Artist">
<Indicator.Thumb class="tabs__thumb" />
{#each Object.entries(tabs) as [value, label] (value)}
<Indicator.Item {value} role="tab" aria-selected={tab === value}>{label}</Indicator.Item>
{/each}
</Indicator.Root>.tabs {
position: relative;
isolation: isolate;
display: flex;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 9%, transparent);
/* Dragging the thumb shouldn't scroll the page or select text */
touch-action: none;
user-select: none;
}
.tabs button { flex: 1; height: 32px; border-radius: 999px; }
/* Capsule tabs give a capsule thumb: white in light mode, a light veil in dark mode */
.tabs__thumb {
position: absolute;
z-index: -1;
background: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12), 0 0 0 0.5px rgb(0 0 0 / 0.04);
}
@media (prefers-color-scheme: dark) {
.tabs__thumb { background: rgb(255 255 255 / 0.22); box-shadow: none; }
}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.
hoverFollows the hovered item until the pointer leaves the container. Over a gap or separator it stays on the last item. Ignores touch.
pressFollows 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.
manualOnly 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.
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.
| 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 |
| 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.
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.
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.
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.
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.
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.
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.
Items with :disabled or aria-disabled="true" are skipped: the indicator hides over them and doesn’t follow focus there.
Safe to import on the server. Create it in the browser after mount, and call destroy() on unmount. With reduced motion, it moves instantly.
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.
Any browser with ES2022, ResizeObserver and MutationObserver. Corner shapes follow the items where corner-shape is supported; elsewhere corners stay round.
touch-action: none so the page doesn’t scroll.update().