NavLink

One entry in a navigation list, marking the current page.

<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>
vue

Usage

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

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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 label remains as aria-label, so announcements are unaffected.
  • The component renders no nav landmark — 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