<script setup lang="ts">
import { BookOpen, Gamepad2, House, Library } from '@lucide/vue'
import { NavLink, Stack } from '@hina-ui/vue'
const items = [
{ icon: House, label: 'Home', active: false },
{ icon: Gamepad2, label: 'Galgame', active: true },
{ icon: BookOpen, label: 'Light novels', active: false },
{ icon: Library, label: 'Manga', active: false },
]
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink v-for="item in items" :key="item.label" href="#" :active="item.active">
<template #icon><component :is="item.icon" /></template>
{{ item.label }}
</NavLink>
</Stack>
</template>
Usage
import { NavLink } from '@hina-ui/vue'
NavLink is one entry in a vertical navigation list. It renders as a link by default and the whole row is clickable. It brings no list container of its own — wrap the entries in a Stack or a nav. The label stays on one line and ends with an ellipsis when it does not fit.
<script setup lang="ts">
import { NavLink, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink href="#">Overview</NavLink>
<NavLink href="#">My shelf</NavLink>
<NavLink href="#">Favourites</NavLink>
</Stack>
</template>
Its split with Link is clear-cut: Link is an inline text link that flows with the sentence around it, while NavLink is a row in a navigation structure with a fixed height and a full-row hit area. Do not use NavLink in body text.
Examples
Current entry
active marks the page the reader is on: the text returns to full colour and weight, and a selected state layer appears. It also sets aria-current="page", which is how screen readers announce the current location.
Only one entry in a group should be active at a time.
<script setup lang="ts">
import { NavLink, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink href="#">Overview</NavLink>
<NavLink href="#" active>My shelf</NavLink>
<NavLink href="#">Favourites</NavLink>
</Stack>
</template>
Hovering an active entry adds the hover layer on top of the selected one rather than replacing it, so contrast only rises and the selected state never gets lost mid-hover.
Icons
The icon slot sits before the text and the component sizes it to four units, so no size class is needed on the icon itself. The icon is decoration; the text is the readable name.
<script setup lang="ts">
import { CircleUser, House, Settings } from '@lucide/vue'
import { NavLink, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink href="#" active>
<template #icon><House /></template>
Overview
</NavLink>
<NavLink href="#">
<template #icon><CircleUser /></template>
Account
</NavLink>
<NavLink href="#">
<template #icon><Settings /></template>
Settings
</NavLink>
</Stack>
</template>
Trailing content
The trailing slot sits after the text at the end of the row, for page counts, counters or shortcuts. When the title does not fit, only the title is truncated and the trailing content stays whole; in a rail sidebar it hides together with the text.
<script setup lang="ts">
import { NavLink, Stack, Text } from '@hina-ui/vue'
const chapters = [
{ title: 'Prologue', pages: '3 pages' },
{
title: 'Chapter 1: After the rain, only the two of us were left on the platform',
pages: '28 pages',
active: true,
},
{ title: 'Chapter 2: Summer festival', pages: '19 pages' },
{ title: 'Epilogue', pages: '6 pages' },
]
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink v-for="chapter in chapters" :key="chapter.title" href="#" :active="chapter.active">
{{ chapter.title }}
<template #trailing>
<Text as="span" size="sm" tone="faint" class="tabular-nums">{{ chapter.pages }}</Text>
</template>
</NavLink>
</Stack>
</template>
Router links
as sets what the entry renders as, defaulting to a. Pass a router component for client-side navigation and attributes such as to pass through. The caller decides which entry is current from the route and passes active; the component does no route matching of its own.
<script setup lang="ts">
import { NuxtLink } from '#components'
import { NavLink, Stack } from '@hina-ui/vue'
const route = useRoute()
const links = [
{ to: '/components/breadcrumb', label: 'Breadcrumb' },
{ to: '/components/nav-link', label: 'NavLink' },
{ to: '/components/splitter', label: 'Splitter' },
]
const localePath = useLocalePath()
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink
v-for="link in links"
:key="link.to"
:as="NuxtLink"
:to="localePath(link.to)"
:active="route.path === localePath(link.to)"
>
{{ link.label }}
</NavLink>
</Stack>
</template>
Unavailable
disabled suits an entry that cannot be entered for now: it drops to half opacity, ignores mouse clicks, leaves the keyboard tab order and carries aria-disabled="true".
An entry that stays unavailable should be removed rather than left greyed out.
<script setup lang="ts">
import { NavLink, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack gap="none" as="nav" class="w-full max-w-56">
<NavLink href="#" active>My shelf</NavLink>
<NavLink href="#">Favourites</NavLink>
<NavLink href="#" disabled>Creator centre</NavLink>
</Stack>
</template>
Collapsed sidebar
Inside a Sidebar, NavLink follows the sidebar’s state. When the sidebar collapses to a rail of icons, the text fades out and label is shown as a hover tooltip instead, serving at the same time as the entry’s accessible name.
So every NavLink inside a Sidebar should carry a label, otherwise a collapsed entry has neither visible text nor a readable name. Outside a Sidebar, label has no effect and no tooltip is rendered. See the Sidebar documentation for the pattern.
Navigation labels fade out before the sidebar finishes collapsing and fade in after expansion begins. Use SidebarLabel for the same transition in custom headers and footers.
Accessibility
- The current entry carries
aria-current="page", so position is not conveyed by colour alone. - An unavailable entry carries
aria-disabled="true"and leaves the tab order. - The icon is marked as decoration and stays out of the reading order; the readable name comes from the default slot.
- Collapsed to a rail, the text is invisible but
labelremains asaria-label, so announcements are unaffected. - The component renders no
navlandmark — the surrounding container must provide one.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | false | Whether this is the current page |
disabled | boolean | false | Whether the entry is unavailable |
label | string | — | Tooltip and accessible name when collapsed to a rail |
as | string | Component | 'a' | Element or component to render as |
asChild | boolean | false | Let the slot’s root element do the rendering |
class | string | — | Classes appended to the root |
Remaining attributes pass through to the rendered element, such as href or a router component’s to.
Slots
| Slot | Description |
|---|---|
default | The entry’s text, and its readable name |
icon | An icon shown before the text |
trailing | Content after the text, at the end of the row |