Highlight

A highlight that slides between active items.

<script setup lang="ts">
  import { ref, useId } from 'vue'
  import { Button, Highlight, Inline } from '@hina-ui/vue'

  const items = ['All', 'Galgame', 'Light novel', 'Manga']
  const current = ref(0)
  const id = useId()
</script>

<template>
  <Inline gap="none" class="bg-inset relative isolate rounded-md p-1">
    <Button
      v-for="(item, index) in items"
      :key="item"
      variant="ghost"
      tone="neutral"
      size="sm"
      :aria-current="current === index ? 'true' : undefined"
      :class="current === index ? 'z-0' : 'z-[1]'"
      @click="current = index"
    >
      <Highlight
        v-if="current === index"
        :id="id"
        axis="x"
        class="bg-surface absolute inset-0 -z-10 rounded-md shadow-sm"
      />
      {{ item }}
    </Button>
  </Inline>
</template>
vue

Usage

import { Highlight } from '@hina-ui/vue'
ts

Highlight marks the current item. It measures nothing; class pins it to the box of the active item, and when the active item changes, Motion’s layout animation moves it continuously from the old position to the new one instead of hiding it in one place and showing it in another.

It paints nothing of its own. Background, radius and stacking all come from class.

Moving between items

Render it inside the currently active item, toggled with v-if, and give it an id. When the active item changes the old block unmounts and a new one mounts inside the new item; the shared id lets Motion fly it from the old position to the new one. Generate the id with useId() so several instances on one page do not interfere.

The example above does exactly this: every button holds an absolute inset-0 highlight, and only the active one renders it.

Translation axis

Set axis="x" to animate horizontal translation only, or axis="y" to animate vertical translation only. Position on the other axis follows the current layout immediately. Width and height changes still animate. The default, axis="both", animates translation on both axes.

Tabs and SegmentedControl select the axis from orientation; Anchor uses y.

Covering a range

Lay the list out as a single-column grid with every item on an explicit row and make the highlight a grid item too, spanning the range with grid-row. It stays mounted, and the layout animation stretches it continuously whenever the span changes.

OverviewCharactersStaffRelease
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Button, Grid, Highlight, Inline, Stack, Text } from '@hina-ui/vue'

  const rows = ['Overview', 'Characters', 'Staff', 'Release']
  const from = ref(1)
  const to = ref(2)
  const span = computed(
    () => `${Math.min(from.value, to.value) + 1} / ${Math.max(from.value, to.value) + 2}`,
  )
</script>

<template>
  <Stack gap="md" class="w-full max-w-sm">
    <Inline gap="sm">
      <Button size="sm" variant="outline" tone="neutral" @click="from = (from + 1) % rows.length">
        Move start
      </Button>
      <Button size="sm" variant="outline" tone="neutral" @click="to = (to + 1) % rows.length">
        Move end
      </Button>
    </Inline>

    <Grid :cols="1" gap="none" class="border-line relative isolate rounded-lg border p-1">
      <Highlight
        axis="y"
        :style="{ gridRow: span }"
        class="bg-accent-soft col-start-1 -z-10 rounded-md"
      />
      <Text
        v-for="(row, index) in rows"
        :key="row"
        as="span"
        size="sm"
        class="col-start-1 block px-3 py-1.5"
        :style="{ gridRow: index + 1 }"
      >
        {{ row }}
      </Text>
    </Grid>
  </Stack>
</template>
vue

Behavior

  • At rest its position is a plain CSS box that follows the item’s size; nothing is measured or observed.
  • Moving and stretching are continuous; Motion corrects the distortion of radius and shadow while scaling.
  • With reduced motion enabled it snaps into place without a transition.
  • While flying it lives inside the target item. If items are stacking contexts of their own, give idle items a higher layer (such as z-[1]) and the active item z-0, so it passes beneath the text of the others.
  • What the server renders is the real highlight, so the first paint matches the hydrated page.

Accessibility

  • The component is aria-hidden; it is decoration only and stays out of the accessibility tree.
  • The current position must have its own semantics, such as aria-current or aria-pressed on the active item; never rely on the highlight alone.

API

Props

Prop
Type
Default
Description
id
string
—
Shared layout animation id; required when moving between items
axis
'x' | 'y' | 'both'
'both'
Translation axis; width and height animations are preserved
as
'div' | 'li'
'div'
Element to render
class
string
—
Classes appended to the root element