WebberUI

Roving Highlight System

One shared morphing highlight surface that travels between any registered elements across tabs, sidebars, and grids, with built-in roving tabindex keyboard navigation and a synchronized focus ring.

Loading preview…
npx shadcn@latest add https://webberui.com/r/roving-highlight-system.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<RovingHighlightSystem />

Installation

npx shadcn@latest add https://webberui.com/r/roving-highlight-system.json

Or, once registries are configured in components.json, install it as @webberui/roving-highlight-system.

Usage

Wrap any containers and the RovingHighlightItems inside them in a single RovingHighlight, and the whole set shares one travelling highlight. When the selection crosses from one container to another, the highlight surface morphs smoothly across to the new position.

import {
  RovingHighlight,
  RovingHighlightItem,
} from "@/components/ui/roving-highlight-system";

<RovingHighlight defaultValue="overview" variant="solid">
  {/* Container one: tab strip */}
  <div className="flex gap-1">
    <RovingHighlightItem value="overview">Overview</RovingHighlightItem>
    <RovingHighlightItem value="activity">Activity</RovingHighlightItem>
  </div>

  {/* Container two: sidebar — the highlight travels all the way here from the tab strip */}
  <div className="flex flex-col gap-1">
    <RovingHighlightItem value="inbox">Inbox</RovingHighlightItem>
    <RovingHighlightItem value="starred">Starred</RovingHighlightItem>
  </div>
</RovingHighlight>;

Controlled mode

Pass value and onValueChange to enter controlled mode, where the selection state is owned by the outside:

const [value, setValue] = React.useState("overview");

<RovingHighlight value={value} onValueChange={setValue}>
  {/* ... */}
</RovingHighlight>;

Selection follows focus

With selectOnFocus on, moving focus with the arrow keys also selects (automatic activation), so the highlight tracks focus directly:

<RovingHighlight defaultValue="overview" selectOnFocus>
  {/* ... */}
</RovingHighlight>

Props

RovingHighlight

PropTypeDefaultDescription
valuestring | nullControlled selected value
defaultValuestring | nullnullInitial selected value in uncontrolled mode
onValueChange(value: string) => voidFires when the selection changes
variant"solid" | "outline" | "underline""solid"Highlight style: filled surface / outline ring / underline
orientation"horizontal" | "vertical" | "both""both"Arrow-key navigation direction
loopbooleantrueWhether navigation wraps around to the other end at the boundaries
selectOnFocusbooleanfalseWhether moving focus onto an item also selects it
childrenReact.ReactNodeAny containers and the RovingHighlightItems inside them

RovingHighlightItem

PropTypeDefaultDescription
valuestringThis item's unique value (must be unique within the set)
disabledbooleanfalseDisabled: not selectable, skipped in keyboard navigation, never a tab stop
onSelect(value: string) => voidFires when it is selected (by click or keyboard Enter/Space)
childrenReact.ReactNodeItem content (text, icons, and so on)

All other native button attributes (aria-label, onClick, and so on) are forwarded to the underlying element.

How it works

  • Travelling across containers: only the selected item in the whole set renders the highlight surface; on switch, the old position unmounts and the new one mounts, and Motion's layoutId shared layout animation takes over so the highlight morphs smoothly across different containers.
  • Namespace isolation: each RovingHighlight names its layoutId through a LayoutGroup, so multiple instances on the same page never pair their highlights with each other.
  • Three highlight styles: solid filled surface, outline ring, underline — all of them travel.

Accessibility

  • Roving tabindex: the whole set has exactly one tab stop (focus > selection > first available item); Tab enters, arrow keys roam across every registered item, Home / End jump to the first and last, Enter / Space select.
  • Focus ring synchronization: a :focus-visible-like detection shows the focus ring only for keyboard operation; the focus ring is likewise a shared element and morphs across containers as focus moves.
  • The selected item carries aria-current="true"; disabled items carry aria-disabled and are excluded from keyboard navigation.
  • When the user has "reduce motion" enabled at the system level, the highlight and focus ring simply appear at their new position instead of travelling.

On this page