Skip to content

Sliding Indicator

Highlight the selected item with an indicator that glides between options.

Use it for the selection or hover highlight of a group: tabs, segmented controls, toggle groups and navigation. It reads the selection from the items' ARIA and Base UI attributes, so it drops into a component with no wiring to its state. Don't use it as the only sign of selection. It's decoration, and the items must still carry their state, because that state is what it reads.

Installation

npm i @youj/motion

import { SlidingIndicator } from '@youj/motion'

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Usage

Render one inside the group's container, next to the items, and give the container relative isolate.

import { SlidingIndicator } from '@youj/motion'

export function RangePicker({ value, onChange }: { value: string; onChange: (range: string) => void }) {
  return (
    <div role="group" aria-label="Time range" className="relative isolate inline-flex rounded-lg bg-well p-0.5">
      <SlidingIndicator className="rounded-md bg-surface shadow-sm" />
      {['24h', '7d', '30d'].map((range) => (
        <button key={range} type="button" aria-pressed={value === range} onClick={() => onChange(range)} className="h-7 px-2.5 text-xs">
          {range}
        </button>
      ))}
    </div>
  )
}

The indicator treats the container's other children as items and looks for the selected one, on each item or inside it:

  • ARIA: aria-selected="true", aria-current (any value but false), aria-pressed="true" and aria-checked="true".
  • Base UI: data-active, data-pressed, data-checked and data-selected.
  • data-state="active", "on" or "checked", as Radix-style components write it.

A mutation observer watches exactly those attributes, so the indicator moves however the selection changes: a click, an arrow key, or a route change. When nothing matches, it fades out, and it fades back in place when something does.

Examples

The container

The container needs two classes, and each one does a job:

  • relative makes the container the indicator's containing block. The indicator is absolutely positioned and measures each item against it.
  • isolate starts a stacking context. The indicator sits at -z-1, behind the items' text. Without the stacking context, it would paint behind the group's own background, out of sight.

The selected item needs no background of its own, because the indicator is its background. Give the item a text color for its selected state and let the indicator do the rest.

Reference

Propsvariant, follow, match… (4)

Props

SlidingIndicator props
PropType
variant'pill' | 'underline'

'pill' fills the item; 'underline' is a bar along its bottom edge.

default 'pill'

'pill'
follow'active' | 'hover'

'active' follows the selected item; 'hover' follows the pointer and keyboard focus and fades out when they leave.

default 'active'

'active'
matchstring

Selector for the active item, tested on the items and inside them.

default ACTIVE

ACTIVE
lingerboolean

When nothing matches (or the pointer leaves, with follow="hover"), hold the highlight on its last item for the fast token before fading, so crossing a gap, a separator or a group label glides on to the next item instead of blinking out. Menus want this. No hold under reduced motion.

default false

false
Other props pass through: extends HTMLAttributes<HTMLSpanElement>.
MotionGlide, appear and fade, linger · reduced motion

Motion

Motion tokens
MomentTokens
Glideposition and size, as a transform
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Appear and fadethe hover pill, or no selection
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Lingerwith linger, the hold before the pill fades
duration.fast

140ms

140ms

Reduced motion

The indicator jumps to the new item. The hover pill appears and disappears without a fade.

The glide is Motion's layout projection: the indicator takes the new item's box, and a transform animates the difference, so nothing triggers layout. A new target mid-glide retargets from wherever the indicator is and keeps its velocity. Click through the tabs in the first preview quickly to see it.

Reduced motion

The indicator still marks the selected item. It moves there in one frame instead of gliding, and the hover pill shows and hides without a fade. The information is the same, it just doesn't travel.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • The indicator is aria-hidden and ignores the pointer. The selection lives on the items, and that's what assistive technology announces.
  • Keep a visible focus ring on the items. With follow="hover", the pill follows keyboard focus as well as the pointer, but only for :focus-visible focus, so a mouse click doesn't leave a pill behind.
  • Check the selected item's text against the indicator's color, not the group's background. That's the pair people read.