<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>
Usage
import { Highlight } from '@hina-ui/vue'
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.
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.
<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>
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 itemz-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-currentoraria-pressedon 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 |