auto, the defaultHover for a mouse or a pen, press for a finger, decided per event, so a tablet with a trackpad and a touchscreen gets both.
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.
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.
Install.
pnpm add @fluidkit/indicatorPut 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>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();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.
<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")!,
});/* Glass is positioned here and a stacking context of its own: no `isolation` needed */
.menu {
position: relative;
width: 248px;
display: grid;
padding: 8px;
border-radius: 32px;
/* A press drags across the items: no page scrolling, no text selection */
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 a pointer is down on its item */
.highlight[data-pressed] {
background: color-mix(in srgb, currentColor 14%, transparent);
}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.
<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")!,
// It shows the choice; 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;
});
}.tabs {
position: relative;
isolation: isolate;
display: flex;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 9%, transparent);
/* A press drags the thumb across: no page scrolling, no text selection */
touch-action: none;
user-select: none;
}
.tabs button { flex: 1; height: 32px; border-radius: 999px; }
/* Capsule tabs make a capsule thumb: white in the light, a light veil in the dark */
.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 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 defaultHover for a mouse or a pen, press for a finger, decided per event, so a tablet with a trackpad and a touchscreen gets both.
hoverThe 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.
pressThe 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.
manualNothing 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.
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.
| 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 |
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.
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.
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.
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.
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.
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.
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.
Any browser with ES2022, ResizeObserver and MutationObserver. Corner shapes follow the items where corner-shape is supported; elsewhere corners stay round.
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.update().