
Naive Ui
- 368 installs
- 22 repo stars
- Updated February 22, 2026
- sepush/naive-ui-skills
Helps with ai & agent building tasks.
About
naive-ui is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- naive-ui
- AI & Agent Building
- AI-coding skill
Naive Ui by the numbers
- 368 all-time installs (skills.sh)
- +13 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,087 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sepush/naive-ui-skills --skill naive-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 368 |
|---|---|
| repo stars | ★ 22 |
| Last updated | February 22, 2026 |
| Repository | sepush/naive-ui-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Naive UI Practices
Usage guide for Naive UI and Vue 3 applications. Contains 33 rules across 8 categories, prioritized by impact to guide automated refactoring, component creation, and troubleshooting.
When to Apply
Reference these guidelines when:
- Writing new Vue 3 components using Naive UI
- Implementing forms and data validation
- Handling large datasets with virtual scrolling (DataTable, Tree, Select)
- Customizing application themes and dark mode
- Troubleshooting Naive UI usage issues and styling conflicts
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Core Configuration | CRITICAL | core- |
| 2 | Form & Data Input | CRITICAL | component- |
| 3 | Complex Data Display | HIGH | component- |
| 4 | Feedback & Overlay | HIGH | component- |
| 5 | Layout & Navigation | MEDIUM | component- |
| 6 | Basic Data Display | MEDIUM | component- |
| 7 | Advanced Customization | LOW-MEDIUM | core- |
| 8 | Ecosystem & Troubleshooting | LOW | core- |
Quick Reference
1. Core Configuration (CRITICAL)
core-setup- Auto-import setup, global config, providerscore-theme- Theme overrides, dark mode, CSS variables, useThemeVarscore-import-on-demand- Import on demand and tree shakingcore-nuxtjs- Nuxt.js integration best practicescore-ssr- Server-Side Rendering guidelinescore-style-conflict- Potential style conflict resolution
2. Form & Data Input (CRITICAL)
component-form-validation- Validation rules, dynamic forms, array fieldscomponent-form- Layout, validation, dynamic fields, nested formscomponent-input- Formatted input, precision, validation triggerscomponent-select- Filterable, multiple, tags, async search, custom rendercomponent-datepicker- Date ranges, shortcuts, formatting, timezonecomponent-upload- Custom request, file handling, drag drop
3. Complex Data Display (HIGH)
component-datatable- Virtual scroll, remote data, sorting, filtering, fixed columnscomponent-tree- Async loading, checkable, controlled state, large dataset handlingcomponent-virtual-list- Virtual scrolling patterns
4. Feedback & Overlay (HIGH)
component-modal- Form modals, draggable, focus management, async closecomponent-feedback- Programmatic API, global methods (Message, Notification)component-feedback-alert- Alert, Skeleton, Spin, LoadingBar, Popconfirm
5. Layout & Navigation (MEDIUM)
component-layout- Layout, Grid, Flex, Space, Card, Dividercomponent-menu- Menu, Dropdown, Breadcrumb, Tabscomponent-navigation-steps- Steps, Timeline, Pagination, Anchor, BackTop
6. Basic Data Display (MEDIUM)
component-data-display- Image, List, Descriptions, Calendar, Time, Countdowncomponent-display- Badge, Tag, Avatar, Progress, Statistic, Result, Emptycomponent-button- Button variants, loading, iconscomponent-selection- Radio, Checkbox, Switch, Slider, Rate, ColorPicker
7. Advanced Customization (LOW-MEDIUM)
core-customize-theme- Customizing theme extensivelycore-fonts- Configuring fontscore-i18n- Internationalizationcore-controlled-uncontrolled- Controlled manner & uncontrolled manner
8. Ecosystem & Troubleshooting (LOW)
core-troubleshooting- Common issues and quick fixescore-installation- Installation guidelinescore-jsx- JSX & TSX usagecore-umd- Using UMD buildcore-usage-sfc- Usage in SFCcore-vite-ssge- Vite SSG/SSE integrationcore-vitepress- Vitepress integrationcore-experimental-features- Experimental features
How to Use
Read individual rule files for detailed explanations and code examples:
references/core-setup.md
references/component-datatable.mdEach rule file contains:
- Specific implementation details
- Code examples for Vue 3 setup script
- Warning notes and prerequisites
- Relevant TypeScript interfaces
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
{
"version": "2026.2.23",
"date": "2026-02-23",
"abstract": "naive-ui skills for AI coding agents , generated from naive-ui docs issues and discussions",
"references": [
"https://github.com/tusen-ai/naive-ui"
]
}Naive UI Skill
Skills for AI coding agents to use naive-ui components, or troubleshooting naive-ui usage issues.
Contents
SKILL.md: Main index and quick reference for the skillreferences/: Detailed documentation for specific features and componentscore-*.md: Core functionality (setup, theme, form validation)component-*.md: Component-specific usage guides
Usage
See the top-level README for installation and usage instructions. You can install this skill to your agent to help it write better code with Naive UI.
Button
Button component with multiple types, sizes, states, and icon support.
Basic Usage
<template>
<n-space>
<n-button>Default</n-button>
<n-button type="tertiary">Tertiary</n-button>
<n-button type="primary">Primary</n-button>
<n-button type="info">Info</n-button>
<n-button type="success">Success</n-button>
<n-button type="warning">Warning</n-button>
<n-button type="error">Error</n-button>
</n-space>
</template>Button Types
Default
<template>
<n-button>Default Button</n-button>
</template>Primary
<template>
<n-button type="primary">Primary</n-button>
</template>Dashed
<template>
<n-button dashed>Dashed Button</n-button>
</template>Ghost
<template>
<div style="background: #000; padding: 20px">
<n-button ghost>Ghost Button</n-button>
<n-button ghost type="primary">Primary Ghost</n-button>
</div>
</template>Text
<template>
<n-button text>Text Button</n-button>
<n-button text tag="a" href="https://example.com">
Link Button
</n-button>
</template>Sizes
<template>
<n-space align="baseline">
<n-button size="tiny">Tiny</n-button>
<n-button size="small">Small</n-button>
<n-button size="medium">Medium</n-button>
<n-button size="large">Large</n-button>
</n-space>
</template>Icon Buttons
With Icon Component
<script setup lang="ts">
import { CashOutline as CashIcon } from '@vicons/ionicons5'
</script>
<template>
<n-space>
<n-button>
<template #icon>
<n-icon :component="CashIcon" />
</template>
With Icon
</n-button>
<n-button circle>
<template #icon>
<n-icon :component="CashIcon" />
</template>
</n-button>
<n-button text circle>
<template #icon>
<n-icon :component="CashIcon" />
</template>
</n-button>
</n-space>
</template>Icon Position
<template>
<n-space>
<n-button>
<template #icon>
<n-icon :component="ArrowForwardIcon" />
</template>
Icon Left
</n-button>
<n-button>
Icon Right
<template #icon>
<n-icon :component="ArrowForwardIcon" />
</template>
</n-button>
</n-space>
</template>Loading State
<script setup lang="ts">
import { ref } from 'vue'
const loading = ref(false)
const handleClick = async () => {
loading.value = true
try {
await someAsyncOperation()
} finally {
loading.value = false
}
}
</script>
<template>
<n-space>
<n-button :loading="loading" @click="handleClick">
Click to Load
</n-button>
<n-button :loading="loading" type="primary">
Loading...
</n-button>
<n-button :loading="loading" circle>
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
</n-button>
</n-space>
</template>Disabled State
<template>
<n-space>
<n-button disabled>Disabled</n-button>
<n-button type="primary" disabled>Primary Disabled</n-button>
<n-button text disabled>Text Disabled</n-button>
</n-space>
</template>Block Button
<template>
<n-button block type="primary">Full Width Button</n-button>
</template>Button Group
<template>
<n-button-group>
<n-button>Left</n-button>
<n-button>Middle</n-button>
<n-button>Right</n-button>
</n-button-group>
<n-button-group vertical>
<n-button>Top</n-button>
<n-button>Middle</n-button>
<n-button>Bottom</n-button>
</n-button-group>
</template>Color Customization
<template>
<n-space>
<n-button color="#8a2be2">Custom Color</n-button>
<n-button color="#ff69b4" text-color="#000">Custom Text Color</n-button>
<n-button text-color="#ff69b4" type="primary">Custom Text on Primary</n-button>
</n-space>
</template>Async Click Handling
<script setup lang="ts">
const handleAsyncClick = async () => {
await new Promise(resolve => setTimeout(resolve, 2000))
console.log('Async operation completed')
}
</script>
<template>
<n-button @click="handleAsyncClick">
Auto Loading on Async
</n-button>
</template>Router Integration
<template>
<n-space>
<n-button tag="router-link" to="/home">
Go Home
</n-button>
<n-button tag="a" href="https://example.com" target="_blank">
External Link
</n-button>
</n-space>
</template>Common Patterns
Form Actions
<template>
<n-space justify="end">
<n-button @click="resetForm">Reset</n-button>
<n-button type="primary" @click="submitForm" :loading="submitting">
Submit
</n-button>
</n-space>
</template>Confirmation Button
<script setup lang="ts">
import { useDialog } from 'naive-ui'
const dialog = useDialog()
const handleDelete = () => {
dialog.warning({
title: 'Confirm Delete',
content: 'Are you sure you want to delete this item?',
positiveText: 'Delete',
negativeText: 'Cancel',
onPositiveClick: () => {
deleteItem()
}
})
}
</script>
<template>
<n-button type="error" @click="handleDelete">
Delete
</n-button>
</template>Copy to Clipboard
<script setup lang="ts">
import { useMessage } from 'naive-ui'
const message = useMessage()
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success('Copied to clipboard')
} catch (err) {
message.error('Failed to copy')
}
}
</script>
<template>
<n-button text @click="copyToClipboard('Some text')">
<template #icon>
<n-icon :component="CopyIcon" />
</template>
Copy
</n-button>
</template>Key Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | `'default' \ | 'primary' \ | 'info' \ |
size | `'tiny' \ | 'small' \ | 'medium' \ |
dashed | boolean | false | Dashed style |
ghost | boolean | false | Ghost style |
text | boolean | false | Text style |
circle | boolean | false | Circle shape |
round | boolean | false | Rounded corners |
loading | boolean | false | Loading state |
disabled | boolean | false | Disabled state |
block | boolean | false | Full width |
tag | string | 'button' | HTML tag or component |
Key Types
import type {
ButtonProps,
ButtonInst,
ButtonGroupProps
} from 'naive-ui'Async Button Click Errors Not Caught by errorHandler
Vue's app.config.errorHandler doesn't catch errors in async button handlers because the error occurs in a promise that Vue doesn't track.
Incorrect (error not caught by errorHandler):
<template>
<n-button type="primary" @click="login">Login</n-button>
</template>
<script setup lang="ts">
async function login(): Promise<void> {
await loginForm.value?.validate()
await authService.login(user.value) // Incorrect: Error here not caught
await router.push('/dashboard')
}
</script>Correct Solution 1: Explicit try-catch
<template>
<n-button
type="primary"
:loading="loading"
@click="handleLogin"
>
Login
</n-button>
</template>
<script setup lang="ts">
const message = useMessage()
const loading = ref(false)
async function handleLogin(): Promise<void> {
loading.value = true
try {
await loginForm.value?.validate()
await authService.login(user.value)
await router.push('/dashboard')
} catch (error: any) {
// Correct: Explicit error handling
message.error(error.message || 'Login failed')
} finally {
loading.value = false
}
}
</script>Correct Solution 2: Wrap in synchronous handler
<template>
<n-button @click="() => login().catch(handleError)">
Login
</n-button>
</template>
<script setup lang="ts">
async function login(): Promise<void> {
await loginForm.value?.validate()
await authService.login(user.value)
}
function handleError(error: any) {
message.error(error.message)
}
</script>Correct Solution 3: Use global error boundary
<script setup lang="ts">
import { onErrorCaptured } from 'vue'
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err)
message.error('An error occurred')
return false // Stop propagation
})
</script>Best Practice:
Always handle async errors explicitly in button handlers. Don't rely on global errorHandler for async operations.
<script setup lang="ts">
// Good pattern: Always wrap async handlers
const handleAsyncAction = async () => {
try {
await someAsyncOperation()
} catch (e) {
// Handle or report error
message.error('Operation failed')
console.error(e)
}
}
</script>Data Display Components
Components for displaying various types of data.
Image
Basic Usage
<template>
<n-image
width="100"
src="https://example.com/image.jpg"
/>
</template>Fallback
<template>
<n-image
src="https://broken-link.jpg"
fallback-src="https://placeholder.com/100"
/>
</template>Preview
<template>
<n-image-group show-toolbar-tooltip>
<n-space>
<n-image
v-for="url in imageUrls"
:key="url"
width="100"
height="100"
:src="url"
/>
</n-space>
</n-image-group>
</template>Custom Preview Toolbar
<template>
<n-image
src="https://example.com/image.jpg"
@download="handleDownload"
>
<template #toolbar>
<n-space>
<n-button @click="rotateLeft">Rotate Left</n-button>
<n-button @click="rotateRight">Rotate Right</n-button>
</n-space>
</template>
</n-image>
</template>Object Fit
<template>
<n-space>
<n-image
width="100"
height="100"
src="https://example.com/wide.jpg"
object-fit="fill"
/>
<n-image
width="100"
height="100"
src="https://example.com/wide.jpg"
object-fit="contain"
/>
<n-image
width="100"
height="100"
src="https://example.com/wide.jpg"
object-fit="cover"
/>
</n-space>
</template>Lazy Loading
<template>
<n-image
lazy
src="https://example.com/large-image.jpg"
placeholder="https://example.com/placeholder.jpg"
/>
</template>Image with Overlay
<template>
<n-image src="https://example.com/image.jpg">
<template #overlay>
<div class="overlay-content">
<n-button type="primary">View Details</n-button>
</div>
</template>
</n-image>
</template>
<style>
.overlay-content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.n-image:hover .overlay-content {
opacity: 1;
}
</style>List
Basic List
<template>
<n-list>
<n-list-item>
<n-thing title="Item 1" description="Description 1" />
</n-list-item>
<n-list-item>
<n-thing title="Item 2" description="Description 2" />
</n-list-item>
</n-list>
</template>Bordered List
<template>
<n-list bordered>
<n-list-item v-for="i in 5" :key="i">
<n-thing :title="'Item ' + i" />
</n-list-item>
</n-list>
</template>Clickable Items
<template>
<n-list hoverable clickable>
<n-list-item
v-for="item in items"
:key="item.id"
@click="handleSelect(item)"
>
<n-thing :title="item.title" />
</n-list-item>
</n-list>
</template>List with Actions
<template>
<n-list>
<n-list-item>
<n-thing title="Item">
<template #description>Description</template>
</n-thing>
<template #suffix>
<n-button text>
<template #icon>
<n-icon :component="EditIcon" />
</template>
</n-button>
<n-button text type="error">
<template #icon>
<n-icon :component="TrashIcon" />
</template>
</n-button>
</template>
</n-list-item>
</n-list>
</template>Virtual List
<template>
<n-list>
<n-virtual-list
:items="items"
:item-size="56"
style="max-height: 400px"
>
<template #default="{ item }">
<n-list-item>
<n-thing :title="item.title" />
</n-list-item>
</template>
</n-virtual-list>
</n-list>
</template>Descriptions
Basic Usage
<template>
<n-descriptions label-placement="top" title="User Info">
<n-descriptions-item label="Name">John Doe</n-descriptions-item>
<n-descriptions-item label="Email">john@example.com</n-descriptions-item>
<n-descriptions-item label="Phone">+1 234 567 890</n-descriptions-item>
</n-descriptions>
</template>Column Layout
<template>
<n-descriptions :columns="3" bordered>
<n-descriptions-item label="Name">John Doe</n-descriptions-item>
<n-descriptions-item label="Age">32</n-descriptions-item>
<n-descriptions-item label="City">New York</n-descriptions-item>
<n-descriptions-item label="Address" :span="2">
123 Main St, New York, NY 10001
</n-descriptions-item>
<n-descriptions-item label="Status">
<n-tag type="success">Active</n-tag>
</n-descriptions-item>
</n-descriptions>
</template>Size Variants
<template>
<n-space vertical>
<n-descriptions size="small" title="Small">
<n-descriptions-item label="Name">John</n-descriptions-item>
</n-descriptions>
<n-descriptions size="medium" title="Medium">
<n-descriptions-item label="Name">John</n-descriptions-item>
</n-descriptions>
<n-descriptions size="large" title="Large">
<n-descriptions-item label="Name">John</n-descriptions-item>
</n-descriptions>
</n-space>
</template>Dynamic Data
<script setup lang="ts">
const userInfo = {
name: 'John Doe',
email: 'john@example.com',
department: 'Engineering',
role: 'Senior Developer',
status: 'Active',
joinDate: '2020-01-15'
}
const items = [
{ label: 'Name', key: 'name' },
{ label: 'Email', key: 'email' },
{ label: 'Department', key: 'department' },
{ label: 'Role', key: 'role' },
{ label: 'Status', key: 'status', render: (val: string) => h(NTag, { type: 'success' }, val) },
{ label: 'Join Date', key: 'joinDate' }
]
</script>
<template>
<n-descriptions bordered label-placement="left" :column="2">
<n-descriptions-item
v-for="item in items"
:key="item.key"
:label="item.label"
>
<template v-if="item.render">
<component :is="item.render(userInfo[item.key])" />
</template>
<template v-else>
{{ userInfo[item.key] }}
</template>
</n-descriptions-item>
</n-descriptions>
</template>Calendar
Basic Usage
<script setup lang="ts">
import { ref } from 'vue'
const value = ref(Date.now())
</script>
<template>
<n-calendar v-model:value="value">
<template #header="{ year, month }">
<div>{{ year }} - {{ month }}</div>
</template>
<template #default="{ year, month, date }">
<div>{{ date }}</div>
</template>
</n-calendar>
</template>With Events
<script setup lang="ts">
const events = ref([
{ date: '2024-01-15', title: 'Meeting' },
{ date: '2024-01-20', title: 'Deadline' }
])
const isEventDay = (ts: number) => {
const date = new Date(ts).toISOString().split('T')[0]
return events.value.some(e => e.date === date)
}
</script>
<template>
<n-calendar v-model:value="value">
<template #default="{ year, month, date }">
<div :class="{ 'has-event': isEventDay(date) }">
{{ new Date(date).getDate() }}
</div>
</template>
</n-calendar>
</template>
<style>
.has-event {
color: #18a058;
font-weight: bold;
}
</style>Time
Basic Usage
<template>
<n-time :time="Date.now()" />
</template>Time Types
<template>
<n-space vertical>
<n-time :time="time" type="date" />
<n-time :time="time" type="datetime" />
<n-time :time="time" type="relative" />
</n-space>
</template>Format
<template>
<n-time
:time="time"
format="yyyy-MM-dd HH:mm:ss"
/>
</template>Relative Time
<template>
<n-space vertical>
<n-time :time="Date.now() - 60000" type="relative" />
<n-time :time="Date.now() - 3600000" type="relative" />
<n-time :time="Date.now() - 86400000" type="relative" />
</n-space>
</template>Countdown
<template>
<n-countdown
:duration="86400000"
:active="active"
@finish="handleFinish"
/>
</template>With Render
<template>
<n-countdown :duration="5000000" :render="renderCountdown" />
</template>
<script setup>
const renderCountdown = ({ hours, minutes, seconds }) => {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
</script>Number Animation
<template>
<n-statistic label="Active Users">
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="12039"
:active="true"
:duration="2000"
:precision="0"
/>
</n-statistic>
</template>Common Patterns
User Profile Card
<template>
<n-card>
<n-space align="start">
<n-avatar :size="80" :src="user.avatar" />
<n-space vertical>
<n-h3>{{ user.name }}</n-h3>
<n-text depth="3">{{ user.email }}</n-text>
<n-space>
<n-tag v-for="role in user.roles" :key="role">{{ role }}</n-tag>
</n-space>
</n-space>
</n-space>
<n-divider />
<n-descriptions :columns="2" size="small">
<n-descriptions-item label="Department">
{{ user.department }}
</n-descriptions-item>
<n-descriptions-item label="Join Date">
<n-time :time="user.joinDate" type="date" />
</n-descriptions-item>
<n-descriptions-item label="Status">
<n-tag :type="user.active ? 'success' : 'error'">
{{ user.active ? 'Active' : 'Inactive' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="Last Active">
<n-time :time="user.lastActive" type="relative" />
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>Gallery Grid
<template>
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="image in images" :key="image.id">
<n-image
:src="image.url"
:alt="image.title"
object-fit="cover"
style="width: 100%; height: 200px; border-radius: 8px"
@click="openPreview(image)"
/>
</n-grid-item>
</n-grid>
</template>Activity Feed
<template>
<n-list>
<n-list-item v-for="activity in activities" :key="activity.id">
<n-thing>
<template #avatar>
<n-avatar :src="activity.user.avatar" />
</template>
<template #header>
{{ activity.user.name }}
<n-text depth="3">{{ activity.action }}</n-text>
{{ activity.target }}
</template>
<template #description>
<n-time :time="activity.timestamp" type="relative" />
</template>
</n-thing>
</n-list-item>
</n-list>
</template>Code & Log (Highlighting)
Warning: Due to package size, Naive UI doesn't includehighlight.js. If you want to use the<n-code>or<n-log>components, make sure you have sethighlight.jsbefore using them. Importinghighlight.json demand is recommended to significantly reduce bundle size.
Code Component Setup
<script setup lang="ts">
import hljs from 'highlight.js/lib/core'
import typescript from 'highlight.js/lib/languages/typescript'
hljs.registerLanguage('typescript', typescript)
</script>
<template>
<n-code :hljs="hljs" code="const a = 1;" language="typescript" />
</template>Log Component Setup
<script setup lang="ts">
import hljs from 'highlight.js/lib/core'
// Define your custom or standard languages
hljs.registerLanguage('naive-log', (hljs) => ({ /* ... */ }))
</script>
<template>
<n-log :hljs="hljs" :log="logContent" language="naive-log" />
</template>Equation (KaTeX)
Warning: Due to package size, Naive UI doesn't include katex. If you want to use Equation, make sure you have setup katex before using it. You can import katex styles in your HTML file: <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css">Key Types
import type {
ImageProps,
ImageGroupProps,
ListProps,
ListItemProps,
DescriptionsProps,
CalendarProps,
TimeProps,
CountdownProps,
NumberAnimationProps
} from 'naive-ui'DataTable
Full-Featured DataTable
<template>
<n-data-table
ref="tableRef"
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
:row-key="row => row.id"
:row-class-name="rowClassName"
:scroll-x="1800"
remote
striped
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:sorter="handleSorterChange"
@update:checked-row-keys="handleCheck"
/>
</template>
<script setup lang="ts">
import type { DataTableColumns, DataTableRowKey } from 'naive-ui'
import { h } from 'vue'
interface RowType {
id: number
name: string
age: number
status: string
}
const loading = ref(false)
const tableData = ref<RowType[]>([])
const checkedRowKeys = ref<DataTableRowKey[]>([])
const columns: DataTableColumns<RowType> = [
{
type: 'selection',
disabled: (row) => row.status === 'disabled'
},
{
title: 'Name',
key: 'name',
width: 150,
fixed: 'left',
ellipsis: { tooltip: true },
sorter: true
},
{
title: 'Age',
key: 'age',
width: 100,
sorter: true,
filter: true,
filterMultiple: false,
filterOptionValue: null,
filterOptions: [
{ label: 'Adult', value: 'adult' },
{ label: 'Minor', value: 'minor' }
]
},
{
title: 'Status',
key: 'status',
render(row) {
return h(NTag, {
type: row.status === 'active' ? 'success' : 'error'
}, { default: () => row.status })
}
},
{
title: 'Action',
key: 'action',
width: 150,
fixed: 'right',
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, {
size: 'small',
onClick: () => handleEdit(row)
}, { default: () => 'Edit' })
]
})
}
}
]
const pagination = ref({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50]
})
const handlePageChange = async (page: number) => {
pagination.value.page = page
await fetchData()
}
const handleSorterChange = async (sorter: any) => {
// Handle server-side sorting
currentSorter.value = sorter
await fetchData()
}
const rowClassName = (row: RowType) => {
return row.status === 'error' ? 'error-row' : ''
}
</script>Virtual Scroll
<template>
<n-data-table
:columns="columns"
:data="data"
virtual-scroll
:scroll-x="1800"
:max-height="500"
row-key="id"
/>
</template>Tree DataTable
<template>
<n-data-table
:columns="columns"
:data="treeData"
default-expand-all
:row-props="rowProps"
/>
</template>
<script setup lang="ts">
const columns = [
{ title: 'Name', key: 'name', tree: true },
{ title: 'Size', key: 'size' }
]
const treeData = [
{
id: 1,
name: 'Folder',
size: '-',
children: [
{ id: 2, name: 'File 1', size: '10KB' },
{ id: 3, name: 'File 2', size: '20KB' }
]
}
]
</script>Fixed Columns
<template>
<n-data-table
:columns="columns"
:data="data"
:scroll-x="1800"
/>
</template>
<script setup lang="ts">
const columns = [
{ title: 'Name', key: 'name', width: 150, fixed: 'left' },
{ title: 'Col 1', key: 'col1', width: 200 },
{ title: 'Col 2', key: 'col2', width: 200 },
{ title: 'Action', key: 'action', width: 150, fixed: 'right' }
]
</script>Custom Cell Rendering
<script setup lang="ts">
import { h } from 'vue'
const columns = [
{
title: 'Progress',
key: 'progress',
render(row) {
return h(NProgress, {
type: 'line',
percentage: row.progress,
showIndicator: true
})
}
},
{
title: 'Link',
key: 'link',
render(row) {
return h('a', {
href: row.url,
target: '_blank'
}, row.name)
}
}
]
</script>Server-Side Operations
<script setup lang="ts">
const fetchData = async () => {
loading.value = true
const { data, total } = await api.getList({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
sortField: currentSorter.value?.columnKey,
sortOrder: currentSorter.value?.order,
filters: currentFilters.value
})
tableData.value = data
pagination.value.itemCount = total
loading.value = false
}
</script>DatePicker
Basic Date Picker
<template>
<n-date-picker v-model:value="timestamp" type="date" />
</template>
<script setup lang="ts">
const timestamp = ref<number>(Date.now())
</script>Date Range
<template>
<n-date-picker
v-model:value="range"
type="datetimerange"
clearable
:shortcuts="shortcuts"
/>
</template>
<script setup lang="ts">
import type { Shortcuts } from 'naive-ui/es/date-picker/src/interface'
const range = ref<[number, number]>([Date.now(), Date.now() + 86400000])
const shortcuts: Shortcuts = {
'Today': () => [Date.now(), Date.now()],
'This Week': () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay())
return [start.getTime(), Date.now()]
},
'This Month': () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return [start.getTime(), Date.now()]
}
}
</script>Formatted Value
<template>
<n-date-picker
v-model:formatted-value="dateString"
value-format="yyyy-MM-dd"
type="date"
clearable
/>
</template>
<script setup lang="ts">
// Use null, not empty string
const dateString = ref<string | null>(null)
</script>Disabled Dates
<template>
<n-date-picker
v-model:value="date"
:is-date-disabled="isDateDisabled"
:is-time-disabled="isTimeDisabled"
/>
</template>
<script setup lang="ts">
import type { IsDateDisabled, IsTimeDisabled } from 'naive-ui/es/date-picker/src/interface'
const date = ref<number>(Date.now())
const isDateDisabled: IsDateDisabled = (timestamp: number) => {
// Disable future dates
return timestamp > Date.now()
}
const isTimeDisabled: IsTimeDisabled = (ts: number, type: 'hour' | 'minute' | 'second', range: 'start' | 'end') => {
// Disable times before 9am and after 6pm
const hour = new Date(ts).getHours()
return hour < 9 || hour > 18
}
</script>DatePicker Empty String Value Throws Error
Setting formatted-value to empty string causes errors. DatePicker expects null for empty values, not empty string.
Incorrect (throws error):
<template>
<n-date-picker
v-model:formatted-value="dateValue"
value-format="yyyy-MM-dd"
/>
</template>
<script setup lang="ts">
// Incorrect: Empty string causes error
const dateValue = ref('')
</script>Correct (use null):
<template>
<n-date-picker
v-model:formatted-value="dateValue"
value-format="yyyy-MM-dd"
clearable
/>
</template>
<script setup lang="ts">
// Correct: Use null for empty
const dateValue = ref<string | null>(null)
</script>Why:
DatePicker internally parses the formatted value. An empty string '' fails parsing, while null is explicitly handled as "no value".
Clearing the value:
<script setup lang="ts">
const dateValue = ref<string | null>('2024-01-01')
// Clear properly
const clearDate = () => {
dateValue.value = null // Correct: Correct
// dateValue.value = '' // Incorrect: Wrong - causes error
}
</script>TypeScript Best Practice:
// Use union type to enforce correct usage
const dateValue = ref<string | null>(null)
// Or with range
const dateRange = ref<[string, string] | null>(null)Alternative (use value instead of formatted-value):
<template>
<n-date-picker
v-model:value="timestamp"
type="date"
clearable
/>
</template>
<script setup lang="ts">
// Timestamp can be null
const timestamp = ref<number | null>(null)
</script>Display Components
Components for displaying data, status, and statistics.
Badge
Basic Usage
<template>
<n-badge :value="5">
<n-avatar src="https://example.com/avatar.jpg" />
</n-badge>
</template>Badge Types
<template>
<n-space>
<n-badge :value="20" :max="15">
<n-button>Messages</n-button>
</n-badge>
<n-badge dot>
<n-icon size="24" :component="NotificationsOutline" />
</n-badge>
<n-badge processing>
<n-icon size="24" :component="NotificationsOutline" />
</n-badge>
</n-space>
</template>Badge with Text
<template>
<n-badge value="NEW">
<n-button>Updates</n-button>
</n-badge>
</template>Offset
<template>
<n-badge :value="10" :offset="[10, 10]">
<n-avatar size="large" />
</n-badge>
</template>Tag
Basic Usage
<template>
<n-space>
<n-tag>Default</n-tag>
<n-tag type="primary">Primary</n-tag>
<n-tag type="info">Info</n-tag>
<n-tag type="success">Success</n-tag>
<n-tag type="warning">Warning</n-tag>
<n-tag type="error">Error</n-tag>
</n-space>
</template>Closable Tags
<script setup lang="ts">
import { ref } from 'vue'
const tags = ref([
{ label: 'Vue', value: 'vue' },
{ label: 'React', value: 'react' },
{ label: 'Angular', value: 'angular' }
])
const removeTag = (value: string) => {
const index = tags.value.findIndex(tag => tag.value === value)
if (index > -1) {
tags.value.splice(index, 1)
}
}
</script>
<template>
<n-space>
<n-tag
v-for="tag in tags"
:key="tag.value"
closable
@close="removeTag(tag.value)"
>
{{ tag.label }}
</n-tag>
</n-space>
</template>Tag Sizes
<template>
<n-space>
<n-tag size="small">Small</n-tag>
<n-tag size="medium">Medium</n-tag>
<n-tag size="large">Large</n-tag>
</n-space>
</template>Round Tags
<template>
<n-tag round>Round Tag</n-tag>
<n-tag round type="primary">Round Primary</n-tag>
</template>Checkable Tags
<script setup lang="ts">
const checked = ref(false)
</script>
<template>
<n-tag v-model:checked="checked" checkable>
Checkable Tag
</n-tag>
</template>Avatar
Basic Usage
<template>
<n-space>
<n-avatar src="https://example.com/avatar.jpg" />
<n-avatar round src="https://example.com/avatar.jpg" />
</n-space>
</template>Avatar Sizes
<template>
<n-space align="center">
<n-avatar size="small" src="..." />
<n-avatar size="medium" src="..." />
<n-avatar size="large" src="..." />
<n-avatar :size="48" src="..." />
</n-space>
</template>Fallback
<template>
<n-avatar
src="https://broken-link.jpg"
fallback-src="https://default-avatar.jpg"
/>
</template>With Object Fit
<template>
<n-avatar
src="https://example.com/image.jpg"
object-fit="cover"
style="width: 100px; height: 100px"
/>
</template>Avatar Group
<template>
<n-avatar-group :options="avatarOptions" :size="40" :max="3">
<template #avatar="{ option: { src } }">
<n-avatar :src="src" />
</template>
<template #rest="{ options: restOptions, rest }">
<n-avatar>+{{ rest }}</n-avatar>
</template>
</n-avatar-group>
</template>Progress
Basic Progress
<template>
<n-progress type="line" :percentage="60" />
<n-progress type="circle" :percentage="60" />
</template>Progress Types
<template>
<n-space vertical>
<n-progress type="line" :percentage="30" />
<n-progress type="line" status="info" :percentage="50" />
<n-progress type="line" status="success" :percentage="70" />
<n-progress type="line" status="warning" :percentage="80" />
<n-progress type="line" status="error" :percentage="90" />
</n-space>
</template>Circle Progress
<template>
<n-space>
<n-progress type="circle" :percentage="75" />
<n-progress type="circle" :percentage="75" status="success" />
<n-progress type="circle" :percentage="75" status="warning" />
<n-progress type="circle" :percentage="75" status="error" />
</n-space>
</template>Dashboard Progress
<template>
<n-progress type="dashboard" :percentage="60" gap-degree="120" />
</template>Multiple Progress
<template>
<n-progress
type="line"
:percentage="[30, 20, 10]"
:color="['#2080f0', '#18a058', '#f0a020']"
/>
</template>Processing State
<template>
<n-progress
type="line"
:percentage="60"
processing
:show-indicator="false"
/>
</template>Statistic
Basic Usage
<template>
<n-statistic label="Active Users" :value="93" />
</template>With Prefix/Suffix
<template>
<n-space>
<n-statistic label="Revenue" :value="12890">
<template #prefix>$</template>
</n-statistic>
<n-statistic label="Growth" :value="15.4">
<template #suffix>%</template>
</n-statistic>
</n-space>
</template>Trend
<template>
<n-space>
<n-statistic label="Sales" :value="12890" trend="up">
<template #suffix>%</template>
</n-statistic>
<n-statistic label="Returns" :value="12" trend="down" />
</n-space>
</template>Number Animation
<script setup lang="ts">
import { ref } from 'vue'
const active = ref(false)
const activate = () => {
active.value = true
}
</script>
<template>
<n-statistic tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="12039"
:active="active"
:duration="2000"
/>
</n-statistic>
<n-button @click="activate">Start</n-button>
</template>Countdown
<template>
<n-countdown
:duration="86400000"
:active="true"
/>
</template>Result
<template>
<n-result
status="success"
title="Operation Successful"
description="Your changes have been saved successfully."
>
<template #footer>
<n-button>Back Home</n-button>
</template>
</n-result>
</template>Result Types
<template>
<n-space vertical>
<n-result status="info" title="Information" />
<n-result status="success" title="Success" />
<n-result status="warning" title="Warning" />
<n-result status="error" title="Error" />
<n-result status="404" title="Not Found" />
<n-result status="403" title="Forbidden" />
<n-result status="500" title="Server Error" />
</n-space>
</template>Empty
<template>
<n-empty description="No data available">
<template #extra>
<n-button size="small">Create New</n-button>
</template>
</n-empty>
</template>Common Patterns
Dashboard Stats
<template>
<n-grid cols="1 s:2 m:4" :x-gap="16">
<n-grid-item>
<n-card>
<n-statistic label="Total Sales" tabular-nums>
<template #prefix>$</template>
<n-number-animation :from="0" :to="28492" />
</n-statistic>
<n-tag size="small" type="success" style="margin-top: 8px">
+12.5% vs last month
</n-tag>
</n-card>
</n-grid-item>
</n-grid>
</template>User List with Avatars
<template>
<n-list>
<n-list-item v-for="user in users" :key="user.id">
<n-thing>
<template #avatar>
<n-badge :value="user.unread" :max="99">
<n-avatar :src="user.avatar" />
</n-badge>
</template>
<template #header>{{ user.name }}</template>
<template #description>{{ user.email }}</template>
<template #header-extra>
<n-tag v-if="user.online" type="success" size="small">
Online
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</template>Key Types
import type {
BadgeProps,
TagProps,
AvatarProps,
ProgressProps,
StatisticProps,
CountdownProps,
ResultProps,
EmptyProps
} from 'naive-ui'Feedback Components
Components for displaying feedback, loading states, and confirmations.
Alert
Basic Usage
<template>
<n-space vertical>
<n-alert title="Default" type="default">
Default alert content
</n-alert>
<n-alert title="Info" type="info">
Info alert content
</n-alert>
<n-alert title="Success" type="success">
Success alert content
</n-alert>
<n-alert title="Warning" type="warning">
Warning alert content
</n-alert>
<n-alert title="Error" type="error">
Error alert content
</n-alert>
</n-space>
</template>Closable
<template>
<n-alert title="Closable" closable @close="handleClose">
You can close this alert
</n-alert>
</template>No Icon
<template>
<n-alert :show-icon="false" title="No Icon">
Alert without icon
</n-alert>
</template>Borderless
<template>
<n-alert title="Borderless" :bordered="false">
Alert without border
</n-alert>
</template>Custom Icon
<script setup lang="ts">
import { Happy } from '@vicons/ionicons5'
</script>
<template>
<n-alert :icon="Happy" title="Custom Icon">
Alert with custom icon
</n-alert>
</template>Description
<template>
<n-alert title="With Description" type="info">
<template #header>Alert Title</template>
Detailed description content goes here. You can provide more context.
</n-alert>
</template>Skeleton
Basic Usage
<template>
<n-skeleton text :repeat="3" />
<n-skeleton style="width: 60%; margin-top: 12px" />
</template>Skeleton Types
<template>
<n-space vertical>
<n-skeleton style="width: 200px; height: 200px" />
<n-skeleton circle style="width: 100px; height: 100px" />
<n-skeleton round style="width: 200px; height: 40px" />
</n-space>
</template>Animated
<template>
<n-skeleton animated />
</template>Size
<template>
<n-space vertical>
<n-skeleton text size="small" />
<n-skeleton text size="medium" />
<n-skeleton text size="large" />
</n-space>
</template>Complex Skeleton
<template>
<n-space vertical>
<n-space>
<n-skeleton circle style="width: 48px; height: 48px" />
<n-space vertical>
<n-skeleton text style="width: 150px" />
<n-skeleton text style="width: 100px" />
</n-space>
</n-space>
<n-skeleton text :repeat="2" />
<n-skeleton style="width: 60%; height: 200px" />
</n-space>
</template>Loading Bar
Basic Usage
<script setup lang="ts">
import { useLoadingBar } from 'naive-ui'
const loadingBar = useLoadingBar()
const startLoading = () => {
loadingBar.start()
}
const finishLoading = () => {
loadingBar.finish()
}
const errorLoading = () => {
loadingBar.error()
}
</script>
<template>
<n-space>
<n-button @click="startLoading">Start</n-button>
<n-button @click="finishLoading">Finish</n-button>
<n-button @click="errorLoading">Error</n-button>
</n-space>
</template>With Router
<script setup lang="ts">
import { useLoadingBar } from 'naive-ui'
import { useRouter } from 'vue-router'
const loadingBar = useLoadingBar()
const router = useRouter()
router.beforeEach(() => {
loadingBar.start()
})
router.afterEach(() => {
loadingBar.finish()
})
</script>Spin
Basic Usage
<template>
<n-spin :show="loading">
<n-card title="Card">
Content that will be covered when spinning
</n-card>
</n-spin>
</template>Spin Sizes
<template>
<n-space>
<n-spin size="small" />
<n-spin size="medium" />
<n-spin size="large" />
</n-space>
</template>Custom Icon
<script setup lang="ts">
import { LogoApple } from '@vicons/ionicons5'
</script>
<template>
<n-spin>
<template #icon>
<n-icon :component="LogoApple" />
</template>
Loading...
</n-spin>
</template>Description
<template>
<n-spin :show="loading" description="Loading...">
<n-card>
Content
</n-card>
</n-spin>
</template>Wrapped Spin
<template>
<div style="display: flex">
<n-spin :show="loading" style="width: 100%">
<n-table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="item.id">
<td>{{ item.name }}</td>
<td>{{ item.value }}</td>
</tr>
</tbody>
</n-table>
</n-spin>
</div>
</template>Popconfirm
Basic Usage
<template>
<n-popconfirm @positive-click="handlePositiveClick">
<template #trigger>
<n-button>Delete</n-button>
</template>
Are you sure you want to delete this?
</n-popconfirm>
</template>Custom Text
<template>
<n-popconfirm
positive-text="Confirm"
negative-text="Cancel"
@positive-click="handleConfirm"
>
<template #trigger>
<n-button type="error">Delete</n-button>
</template>
<template #icon>
<n-icon :component="TrashIcon" color="red" />
</template>
This action cannot be undone.
</n-popconfirm>
</template>Show/Hide
<script setup lang="ts">
const show = ref(false)
const handleConfirm = () => {
show.value = false
deleteItem()
}
</script>
<template>
<n-popconfirm v-model:show="show" @positive-click="handleConfirm">
<template #trigger>
<n-button @click="show = true">Delete</n-button>
</template>
Confirm deletion?
</n-popconfirm>
</template>Loading States
Button Loading
<template>
<n-button :loading="loading" @click="handleClick">
Submit
</n-button>
</template>Input Loading
<template>
<n-input v-model:value="value" loading placeholder="Loading..." />
</template>Select Loading
<template>
<n-select v-model:value="value" :loading="loading" :options="options" />
</template>Common Patterns
Page Loading
<script setup lang="ts">
const loading = ref(true)
const data = ref(null)
onMounted(async () => {
loading.value = true
try {
data.value = await fetchData()
} finally {
loading.value = false
}
})
</script>
<template>
<n-spin :show="loading" size="large" style="min-height: 300px">
<div v-if="data">
<n-h1>{{ data.title }}</n-h1>
<n-p>{{ data.content }}</n-p>
</div>
</n-spin>
</template>Skeleton Screen
<template>
<div v-if="loading">
<n-skeleton style="width: 100%; height: 200px" />
<n-skeleton text size="large" style="margin-top: 12px" />
<n-skeleton text :repeat="3" />
</div>
<div v-else>
<img :src="imageUrl" style="width: 100%; height: 200px; object-fit: cover">
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</div>
</template>List with Loading
<template>
<n-list>
<n-list-item v-for="item in items" :key="item.id">
<n-thing :title="item.title">
<template #description>{{ item.description }}</template>
</n-thing>
</n-list-item>
<template #footer>
<n-spin v-if="loadingMore" size="small">
<template #description>
Loading more...
</template>
</n-spin>
<n-button v-else text @click="loadMore">Load More</n-button>
</template>
</n-list>
</template>Operation Confirmation
<template>
<n-space>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button type="error">Delete</n-button>
</template>
Are you sure to delete this item?
</n-popconfirm>
<n-popconfirm
:show-icon="false"
@positive-click="handlePublish"
>
<template #trigger>
<n-button type="primary">Publish</n-button>
</template>
<template #action>
<n-button size="small" @click="handleSaveAsDraft">
Save as Draft
</n-button>
<n-button size="small" type="primary" @click="handlePublishNow">
Publish Now
</n-button>
</template>
Publish this article?
</n-popconfirm>
</n-space>
</template>Alert Banner
<template>
<n-alert
v-if="showBanner"
title="System Maintenance"
type="warning"
closable
@close="showBanner = false"
>
System will be down for maintenance on Sunday 2:00 AM - 4:00 AM.
</n-alert>
</template>Form Submission
<script setup lang="ts">
const submitting = ref(false)
const handleSubmit = async () => {
submitting.value = true
try {
await submitForm(formData.value)
message.success('Submitted successfully')
} catch (error) {
message.error('Submission failed')
} finally {
submitting.value = false
}
}
</script>
<template>
<n-spin :show="submitting">
<n-form>
<n-button type="primary" @click="handleSubmit">
Submit
</n-button>
</n-form>
<template #description>
Submitting...
</template>
</n-spin>
</template>Key Types
import type {
AlertProps,
SkeletonProps,
SpinProps,
PopconfirmProps,
LoadingBarProviderInst
} from 'naive-ui'Feedback Components
Message
Prerequisite: To usemessage, wrap the calling component insiden-message-providerand useuseMessageto get the API.
Warning: To use it outside setup, mount the return value ofuseMessagetowindowin top-level setup first.
<script setup lang="ts">
const message = useMessage()
message.success('Operation successful')
message.error('Something went wrong')
message.warning('Warning message')
message.info('Info message')
message.loading('Loading...')
// With duration
message.success('Will disappear in 3s', { duration: 3000 })
// Closable
message.info('Click to close', { closable: true })
</script>Dialog
Prerequisite: To usedialog, wrap the calling component insiden-dialog-providerand useuseDialogto get the API.
<script setup lang="ts">
const dialog = useDialog()
dialog.confirm({
title: 'Confirm',
content: 'Are you sure?',
positiveText: 'Yes',
negativeText: 'No',
onPositiveClick: () => {
message.success('Confirmed')
}
})
dialog.warning({
title: 'Warning',
content: 'This action cannot be undone'
})
dialog.error({
title: 'Error',
content: 'Operation failed'
})
</script>Notification
Prerequisite: To usenotification, wrap the calling component insiden-notification-providerand useuseNotificationto get the API.
<script setup lang="ts">
const notification = useNotification()
notification.success({
title: 'Success',
content: 'Operation completed',
duration: 3000
})
notification.error({
title: 'Error',
content: 'Something went wrong',
meta: '2024-01-01',
action: () => h(NButton, { text: true }, { default: () => 'Retry' })
})
</script>LoadingBar
Prerequisite: To useloading-bar, wrap the calling component insiden-loading-bar-providerand injectloadingBar.
<script setup lang="ts">
const loadingBar = useLoadingBar()
loadingBar.start()
loadingBar.finish()
loadingBar.error()
</script>Global Setup
// main.ts
import { createApp } from 'vue'
import {
create,
NMessageProvider,
NDialogProvider,
NNotificationProvider,
NLoadingBarProvider
} from 'naive-ui'
import App from './App.vue'
const app = createApp(App)
app.use(create({
providers: [
NMessageProvider,
NDialogProvider,
NNotificationProvider,
NLoadingBarProvider
]
}))Form Validation
Basic Validation
<template>
<n-form ref="formRef" :model="formValue" :rules="rules">
<n-form-item path="name" label="Name">
<n-input v-model:value="formValue.name" />
</n-form-item>
<n-form-item path="email" label="Email">
<n-input v-model:value="formValue.email" />
</n-form-item>
<n-button @click="handleValidate">Submit</n-button>
</n-form>
</template>
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
const formRef = ref<FormInst | null>(null)
const formValue = ref({
name: '',
email: ''
})
const rules: FormRules = {
name: {
required: true,
message: 'Please input name',
trigger: ['blur', 'input']
},
email: {
required: true,
type: 'email',
message: 'Please input valid email',
trigger: ['blur']
}
}
const handleValidate = async () => {
try {
await formRef.value?.validate()
message.success('Valid!')
} catch (errors) {
console.error(errors)
}
}
</script>Array Field Validation
<script setup lang="ts">
const formValue = ref({
items: [
{ name: '', quantity: 1 },
{ name: '', quantity: 1 }
]
})
const rules = {
items: {
type: 'array',
validator(rule, value) {
if (!Array.isArray(value) || value.length === 0) {
return new Error('At least one item required')
}
for (let i = 0; i < value.length; i++) {
if (!value[i].name?.trim()) {
return new Error(`Item ${i + 1} name is required`)
}
if (value[i].quantity <= 0) {
return new Error(`Item ${i + 1} quantity must be > 0`)
}
}
return true
}
}
}
</script>Dynamic Form Validation
<template>
<n-form ref="formRef" :model="form" :rules="rules">
<div v-for="(field, index) in form.fields" :key="index">
<n-form-item
:path="`fields[${index}].value`"
:rule="dynamicRule"
>
<n-input v-model:value="field.value" />
<n-button @click="removeField(index)">Remove</n-button>
</n-form-item>
</div>
<n-button @click="addField">Add Field</n-button>
</n-form>
</template>
<script setup lang="ts">
const form = ref({
fields: [{ value: '' }]
})
const dynamicRule = {
required: true,
message: 'This field is required'
}
const addField = () => {
form.value.fields.push({ value: '' })
}
const removeField = (index) => {
form.value.fields.splice(index, 1)
}
</script>Nested Form Validation
<template>
<n-form :model="form" :rules="rules">
<n-card title="Basic Info">
<n-form-item path="basic.name" label="Name">
<n-input v-model:value="form.basic.name" />
</n-form-item>
</n-card>
<n-card title="Address">
<n-form-item path="address.street" label="Street">
<n-input v-model:value="form.address.street" />
</n-form-item>
</n-card>
</n-form>
</template>
<script setup lang="ts">
const form = ref({
basic: { name: '' },
address: { street: '' }
})
const rules = {
basic: {
name: { required: true }
},
address: {
street: { required: true }
}
}
</script>Partial Validation
// Validate single field
await formRef.value?.validate(['name'])
// Validate multiple specific fields
await formRef.value?.validate(['name', 'email'])
// Validate with callback
await formRef.value?.validate(
(errors) => {
if (!errors) {
console.log('Valid!')
}
},
(rule) => rule.key === 'name' // Filter rules
)Async Validation
const rules = {
username: {
required: true,
validator(rule, value) {
return new Promise((resolve, reject) => {
checkUsernameExists(value).then(exists => {
if (exists) {
reject(new Error('Username already taken'))
} else {
resolve()
}
})
})
},
trigger: ['blur']
}
}Custom Validation
const rules = {
password: {
required: true,
validator(rule, value) {
if (!value) {
return new Error('Password is required')
}
if (value.length < 8) {
return new Error('Password must be at least 8 characters')
}
if (!/[A-Z]/.test(value)) {
return new Error('Password must contain uppercase letter')
}
return true
}
},
confirmPassword: {
required: true,
validator(rule, value) {
if (value !== formValue.value.password) {
return new Error('Passwords do not match')
}
return true
}
}
}Form Component
💡 Important: If you need to setrequiredfor a form item whose value is of typenumber(like when usingn-input-number), you MUST specifytype: 'number'in the rule object. Otherwise, the validation will fail or trigger incorrectly.
Inline Form
<template>
<n-form inline :model="form" :rules="rules">
<n-form-item path="name" label="Name">
<n-input v-model:value="form.name" />
</n-form-item>
<n-form-item path="status" label="Status">
<n-select v-model:value="form.status" :options="statusOptions" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="search">Search</n-button>
</n-form-item>
</n-form>
</template>Grid Layout Form
<template>
<n-form :model="form" :rules="rules">
<n-grid :cols="2" :x-gap="24">
<n-form-item-gi path="firstName" label="First Name">
<n-input v-model:value="form.firstName" />
</n-form-item-gi>
<n-form-item-gi path="lastName" label="Last Name">
<n-input v-model:value="form.lastName" />
</n-form-item-gi>
</n-grid>
</n-form>
</template>Dynamic Form Fields
<template>
<n-form ref="formRef" :model="form" :rules="rules">
<n-divider>Items</n-divider>
<div v-for="(item, index) in form.items" :key="index">
<n-space align="start">
<n-form-item
:path="`items[${index}].name`"
:rule="{ required: true, message: 'Required' }"
>
<n-input v-model:value="item.name" placeholder="Item name" />
</n-form-item>
<n-form-item
:path="`items[${index}].quantity`"
:rule="{ type: 'number', min: 1 }"
>
<n-input-number v-model:value="item.quantity" :min="1" />
</n-form-item>
<n-button
circle
type="error"
@click="removeItem(index)"
:disabled="form.items.length <= 1"
>
<n-icon><close-icon /></n-icon>
</n-button>
</n-space>
</div>
<n-button dashed block @click="addItem">
+ Add Item
</n-button>
</n-form>
</template>
<script setup lang="ts">
interface FormItem {
name: string
quantity: number
}
interface Form {
items: FormItem[]
}
const form = ref<Form>({
items: [{ name: '', quantity: 1 }]
})
const addItem = () => {
form.value.items.push({ name: '', quantity: 1 })
}
const removeItem = (index: number) => {
form.value.items.splice(index, 1)
}
</script>Form with Async Submit
<template>
<n-form ref="formRef" :model="form" :rules="rules">
<n-form-item path="name" label="Name">
<n-input v-model:value="form.name" />
</n-form-item>
<n-form-item>
<n-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
Submit
</n-button>
</n-form-item>
</n-form>
</template>
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
const formRef = ref<FormInst | null>(null)
const submitting = ref(false)
const form = ref({ name: '' })
const rules: FormRules = {
name: { required: true, message: 'Name is required' }
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitting.value = true
await submitApi(form.value)
message.success('Success')
} catch (e) {
// Handle error
} finally {
submitting.value = false
}
}
</script>
const form = ref({ name: '' })
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitting.value = true
await submitApi(form.value)
message.success('Success')
} catch (e) {
// Handle error
} finally {
submitting.value = false
}
}
</script>Input & InputNumber
Text input components with various features including validation, formatting, and number handling.
Input
Basic Usage
<script setup lang="ts">
import { ref } from 'vue'
const value = ref('')
</script>
<template>
<n-input v-model:value="value" placeholder="Enter text" />
</template>Input Types
<template>
<n-space vertical>
<n-input type="text" placeholder="Text input" />
<n-input type="password" placeholder="Password input" show-password-on="mousedown" />
<n-input type="textarea" placeholder="Textarea" :rows="3" />
</n-space>
</template>Input with Icons
<script setup lang="ts">
import { Search, Person } from '@vicons/ionicons5'
</script>
<template>
<n-space vertical>
<n-input placeholder="Search">
<template #prefix>
<n-icon :component="Search" />
</template>
</n-input>
<n-input placeholder="Username">
<template #suffix>
<n-icon :component="Person" />
</template>
</n-input>
</n-space>
</template>Clearable Input
<template>
<n-input v-model:value="value" clearable placeholder="Can be cleared" />
</template>Input Sizes
<template>
<n-space vertical>
<n-input size="small" placeholder="Small" />
<n-input size="medium" placeholder="Medium (default)" />
<n-input size="large" placeholder="Large" />
</n-space>
</template>Character Count
<template>
<n-input
v-model:value="value"
type="textarea"
maxlength="100"
show-count
clearable
/>
</template>Autosizing Textarea
<template>
<n-input
v-model:value="value"
type="textarea"
:autosize="{
minRows: 3,
maxRows: 5
}"
/>
</template>InputNumber
Basic Usage
<script setup lang="ts">
import { ref } from 'vue'
const value = ref(0)
</script>
<template>
<n-input-number v-model:value="value" />
</template>Min / Max
<template>
<n-input-number v-model:value="value" :min="0" :max="100" />
</template>Step
<template>
<n-input-number v-model:value="value" :step="10" />
</template>Precision
<template>
<n-input-number v-model:value="value" :precision="2" />
</template>Custom Formatter
<script setup lang="ts">
const value = ref(1000)
const format = (value: number | null): string => {
if (value === null) return ''
return `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
const parse = (input: string): number | null => {
const nums = input.replace(/[^\d.]/g, '').trim()
return nums ? Number(nums) : null
}
</script>
<template>
<n-input-number
v-model:value="value"
:format="format"
:parse="parse"
:min="0"
:precision="2"
/>
</template>Button Placement
<template>
<n-space>
<n-input-number v-model:value="value" button-placement="both" />
<n-input-number v-model:value="value" button-placement="right" />
</n-space>
</template>Form Validation Patterns
Basic Validation
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
const formRef = ref<FormInst | null>(null)
const formValue = ref({
name: '',
age: null as number | null
})
const rules: FormRules = {
name: {
required: true,
message: 'Please input name',
trigger: ['blur', 'input']
},
age: {
type: 'number',
required: true,
message: 'Please input age',
trigger: ['blur', 'change']
}
}
const handleValidate = () => {
formRef.value?.validate((errors) => {
if (!errors) {
console.log('Valid')
}
})
}
</script>
<template>
<n-form ref="formRef" :model="formValue" :rules="rules">
<n-form-item label="Name" path="name">
<n-input v-model:value="formValue.name" />
</n-form-item>
<n-form-item label="Age" path="age">
<n-input-number v-model:value="formValue.age" />
</n-form-item>
</n-form>
</template>Warning: Important: InputNumber with Precision/Format
When using precision or format props, trigger: ['input'] may not work correctly.
Problem:
<n-form-item path="amount" :rule="{ trigger: ['input'] }">
<n-input-number v-model:value="amount" :precision="2" />
</n-form-item>Solution:
<script setup lang="ts">
const rules: FormRules = {
amount: {
required: true,
type: 'number',
// Use blur trigger or manual validation
trigger: ['blur']
}
}
</script>
<template>
<n-form-item path="amount" :rule="rules.amount">
<n-input-number v-model:value="amount" :precision="2" />
</n-form-item>
<n-button @click="validate">Validate</n-button>
</template>Async Validation
<script setup lang="ts">
const checkUsername = async (rule: FormItemRule, value: string) => {
if (!value) return new Error('Please input username')
// Simulate API call
const exists = await api.checkUsernameExists(value)
if (exists) {
return new Error('Username already exists')
}
return true
}
const rules: FormRules = {
username: {
required: true,
validator: checkUsername,
trigger: 'blur'
}
}
</script>Dynamic Validation
<script setup lang="ts">
const formValue = ref({
type: 'individual' as 'individual' | 'company',
name: '',
companyCode: ''
})
const rules = computed<FormRules>(() => ({
name: {
required: true,
message: 'Name is required'
},
companyCode: {
required: formValue.value.type === 'company',
message: 'Company code is required for companies'
}
}))
</script>Common Patterns
Search Input with Debounce
<script setup lang="ts">
import { ref, watch } from 'vue'
import { debounce } from 'lodash-es'
const search = ref('')
const results = ref([])
const searchApi = debounce(async (query: string) => {
if (!query) {
results.value = []
return
}
results.value = await api.search(query)
}, 300)
watch(search, searchApi)
</script>
<template>
<n-input
v-model:value="search"
placeholder="Search..."
clearable
>
<template #prefix>
<n-icon :component="Search" />
</template>
</n-input>
</template>Number Range
<script setup lang="ts">
const min = ref<number | null>(null)
const max = ref<number | null>(null)
const validateRange = () => {
if (min.value !== null && max.value !== null && min.value > max.value) {
message.error('Min cannot be greater than max')
}
}
</script>
<template>
<n-space>
<n-input-number
v-model:value="min"
placeholder="Min"
@blur="validateRange"
/>
<span>-</span>
<n-input-number
v-model:value="max"
placeholder="Max"
@blur="validateRange"
/>
</n-space>
</template>Controlled Input
<script setup lang="ts">
const value = ref('')
const handleUpdate = (val: string) => {
// Filter or transform input
value.value = val.replace(/[^\d]/g, '') // Only allow digits
}
</script>
<template>
<n-input
:value="value"
@update:value="handleUpdate"
placeholder="Numbers only"
/>
</template>Key Types
import type {
InputProps,
InputInst,
InputNumberProps,
InputNumberInst
} from 'naive-ui'Layout System
Layout components for structuring your application interface.
Layout
Warning: If you need to usen-drawer-content, you should keepn-drawer'snative-scrollbarprop astrue.
Basic Layout
<template>
<n-layout>
<n-layout-header bordered style="height: 64px; padding: 24px">
Header
</n-layout-header>
<n-layout-content style="padding: 24px; min-height: 400px">
Content
</n-layout-content>
<n-layout-footer bordered style="padding: 24px">
Footer
</n-layout-footer>
</n-layout>
</template>Layout with Sider
<template>
<n-layout style="height: 100vh">
<n-layout-header bordered>Header</n-layout-header>
<n-layout has-sider>
<n-layout-sider bordered content-style="padding: 24px">
Sider
</n-layout-sider>
<n-layout-content content-style="padding: 24px">
Content
</n-layout-content>
</n-layout>
<n-layout-footer bordered>Footer</n-layout-footer>
</n-layout>
</template>Collapsible Sider
<script setup lang="ts">
const collapsed = ref(false)
</script>
<template>
<n-layout has-sider>
<n-layout-sider
collapse-mode="width"
:collapsed-width="64"
:width="240"
:collapsed="collapsed"
show-trigger
@collapse="collapsed = true"
@expand="collapsed = false"
>
<n-menu :collapsed="collapsed" :options="menuOptions" />
</n-layout-sider>
<n-layout-content>Content</n-layout-content>
</n-layout>
</template>Grid
Warning: Due to technical limitation, n-grid-item can't be encapsulated in another component.>
Warning: Then-legacy-gridcomponent is deprecated. In most cases you should usen-gridinstead.
Basic Grid
<template>
<n-grid :cols="3" :x-gap="12" :y-gap="8">
<n-grid-item>
<div class="light-green">1</div>
</n-grid-item>
<n-grid-item>
<div class="green">2</div>
</n-grid-item>
<n-grid-item>
<div class="light-green">3</div>
</n-grid-item>
</n-grid>
</template>
<style>
.light-green { background: #e8f5e9; padding: 16px; }
.green { background: #c8e6c9; padding: 16px; }
</style>Responsive Grid
<template>
<n-grid
cols="1 s:2 m:3 l:4 xl:5"
responsive="screen"
:x-gap="16"
:y-gap="16"
>
<n-grid-item v-for="i in 10" :key="i">
<n-card>{{ i }}</n-card>
</n-grid-item>
</n-grid>
</template>Grid Span
<template>
<n-grid :cols="4" :x-gap="12">
<n-grid-item :span="2">
<div class="light-green">Span 2</div>
</n-grid-item>
<n-grid-item>
<div class="green">Span 1</div>
</n-grid-item>
<n-grid-item>
<div class="light-green">Span 1</div>
</n-grid-item>
<n-grid-item :span="3">
<div class="green">Span 3</div>
</n-grid-item>
<n-grid-item>
<div class="light-green">Span 1</div>
</n-grid-item>
</n-grid>
</template>Offset
<template>
<n-grid :cols="4" :x-gap="12">
<n-grid-item :span="2">
<div class="light-green">Content</div>
</n-grid-item>
<n-grid-item :span="2" :offset="2">
<div class="green">Offset 2</div>
</n-grid-item>
</n-grid>
</template>Flex
<template>
<n-flex vertical>
<n-flex justify="space-between" align="center">
<n-text>Left aligned</n-text>
<n-space>
<n-button>Action 1</n-button>
<n-button>Action 2</n-button>
</n-space>
</n-flex>
<n-flex :size="[16, 16]" wrap>
<n-tag v-for="i in 10" :key="i">Tag {{ i }}</n-tag>
</n-flex>
</n-flex>
</template>Space
Basic Usage
<template>
<n-space>
<n-button>Button 1</n-button>
<n-button>Button 2</n-button>
<n-button>Button 3</n-button>
</n-space>
</template>Vertical Space
<template>
<n-space vertical size="large">
<n-input placeholder="Input 1" />
<n-input placeholder="Input 2" />
<n-input placeholder="Input 3" />
</n-space>
</template>Alignments
<template>
<n-space align="start" style="height: 100px; background: #f5f5f5">
<n-button size="small">Small</n-button>
<n-button size="large">Large</n-button>
<div style="height: 80px; background: #ccc">Fixed height</div>
</n-space>
</template>Card
Basic Card
<template>
<n-card title="Card Title">
Card content
</n-card>
</template>Card with Header Extra
<template>
<n-card title="Users">
<template #header-extra>
<n-button text>
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
Refresh
</n-button>
</template>
Card content
</n-card>
</template>Card with Actions
<template>
<n-card title="Post Title" hoverable>
<p>Card content here...</p>
<template #action>
<n-space>
<n-button>Cancel</n-button>
<n-button type="primary">Submit</n-button>
</n-space>
</template>
<template #footer>
#tag1 #tag2
</template>
</n-card>
</template>Segmented Card
<template>
<n-card :segmented="{
content: true,
footer: 'soft'
}">
<template #header>
Custom Header
</template>
Content with segmented style
<template #footer>
Footer content
</template>
</n-card>
</template>Card Sizes
<template>
<n-space>
<n-card title="Small" size="small" style="width: 200px">
Small card
</n-card>
<n-card title="Medium" size="medium" style="width: 200px">
Medium card (default)
</n-card>
<n-card title="Large" size="large" style="width: 200px">
Large card
</n-card>
<n-card title="Huge" size="huge" style="width: 200px">
Huge card
</n-card>
</n-space>
</template>Divider
Horizontal Divider
<template>
<div>
<p>Content above</p>
<n-divider />
<p>Content below</p>
</div>
</template>Divider with Title
<template>
<n-divider title-placement="left">Left Title</n-divider>
<n-divider title-placement="center">Center Title</n-divider>
<n-divider title-placement="right">Right Title</n-divider>
</template>Vertical Divider
<template>
<n-space>
<span>Text</span>
<n-divider vertical />
<n-a>Link</n-a>
<n-divider vertical />
<n-a>Link</n-a>
</n-space>
</template>Dashed Divider
<template>
<n-divider dashed>
Dashed Style
</n-divider>
</template>Common Layout Patterns
Dashboard Grid
<template>
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16">
<n-grid-item>
<n-card title="Total Users">
<n-statistic tabular-nums>
<n-number-animation ref="numberAnimationInstRef" :from="0" :to="12039" />
</n-statistic>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="Active">
<n-statistic tabular-nums>
<n-number-animation :from="0" :to="4282" />
</n-statistic>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="Orders">
<n-statistic tabular-nums>
<n-number-animation :from="0" :to="892" />
</n-statistic>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="Revenue">
<n-statistic tabular-nums prefix="$">
<n-number-animation :from="0" :to="28492" />
</n-statistic>
</n-card>
</n-grid-item>
</n-grid>
</template>Form Layout
<template>
<n-card title="User Information">
<n-grid cols="1 m:2" :x-gap="24">
<n-grid-item>
<n-form-item label="First Name">
<n-input v-model:value="form.firstName" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="Last Name">
<n-input v-model:value="form.lastName" />
</n-form-item>
</n-grid-item>
<n-grid-item span="2">
<n-form-item label="Address">
<n-input v-model:value="form.address" type="textarea" />
</n-form-item>
</n-grid-item>
</n-grid>
</n-card>
</template>List with Cards
<template>
<n-grid cols="1 s:2 m:3" :x-gap="16" :y-gap="16">
<n-grid-item v-for="item in items" :key="item.id">
<n-card hoverable @click="selectItem(item)">
<template #cover>
<img :src="item.image" style="height: 200px; object-fit: cover">
</template>
<h3>{{ item.title }}</h3>
<p style="color: #666">{{ item.description }}</p>
<template #action>
<n-space>
<n-button size="small">Edit</n-button>
<n-button size="small" type="error">Delete</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
</n-grid>
</template>Key Types
import type {
LayoutProps,
LayoutInst,
GridProps,
GridItemProps,
FlexProps,
SpaceProps,
CardProps,
CardInst,
DividerProps
} from 'naive-ui'Responsive Breakpoints
| Name | Breakpoint | Width |
|---|---|---|
cols | Default | < 640px |
s: | Small | >= 640px |
m: | Medium | >= 1024px |
l: | Large | >= 1280px |
xl: | Extra Large | >= 1536px |
Menu & Navigation
Navigation components including Menu, Dropdown, Breadcrumb, and Tabs.
Menu
Basic Usage
<script setup lang="ts">
import type { MenuOption } from 'naive-ui'
import { h, ref } from 'vue'
import { RouterLink } from 'vue-router'
const menuOptions: MenuOption[] = [
{
label: 'Dashboard',
key: 'dashboard',
icon: renderIcon(DashboardIcon)
},
{
label: 'Users',
key: 'users',
icon: renderIcon(PeopleIcon),
children: [
{ label: 'All Users', key: 'users-list' },
{ label: 'Roles', key: 'users-roles' }
]
},
{
label: 'Settings',
key: 'settings',
icon: renderIcon(SettingsIcon),
children: [
{
label: 'General',
key: 'settings-general',
children: [
{ label: 'Site', key: 'settings-general-site' },
{ label: 'Email', key: 'settings-general-email' }
]
},
{ label: 'Security', key: 'settings-security' }
]
}
]
function renderIcon(icon: Component) {
return () => h(NIcon, null, { default: () => h(icon) })
}
const activeKey = ref<string>('dashboard')
const collapsed = ref(false)
</script>
<template>
<n-menu
v-model:value="activeKey"
:collapsed="collapsed"
:collapsed-width="64"
:options="menuOptions"
@update:value="handleMenuSelect"
/>
</template>Router Integration
<script setup lang="ts">
import type { MenuOption } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
const menuOptions: MenuOption[] = [
{
label: () => h(RouterLink, { to: '/' }, { default: () => 'Home' }),
key: 'home',
icon: renderIcon(HomeIcon)
},
{
label: () => h(RouterLink, { to: '/about' }, { default: () => 'About' }),
key: 'about',
icon: renderIcon(InfoIcon)
},
{
label: 'Products',
key: 'products',
icon: renderIcon(CartIcon),
children: [
{
label: () => h(RouterLink, { to: '/products' }, { default: () => 'All Products' }),
key: 'products-list'
},
{
label: () => h(RouterLink, { to: '/products/categories' }, { default: () => 'Categories' }),
key: 'products-categories'
}
]
}
]
// Auto-select based on route
const activeKey = computed(() => {
return route.name as string || 'home'
})
</script>Accordion Mode
<template>
<n-menu
:options="menuOptions"
accordion
:default-expanded-keys="['users']"
/>
</template>Collapsed Menu
<template>
<n-layout has-sider>
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
:collapsed="collapsed"
show-trigger
@collapse="collapsed = true"
@expand="collapsed = false"
>
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
/>
</n-layout-sider>
<n-layout-content>
Content
</n-layout-content>
</n-layout>
</template>Dropdown
Basic Usage
<script setup lang="ts">
import type { DropdownOption } from 'naive-ui'
const options: DropdownOption[] = [
{
label: 'Edit',
key: 'edit',
icon: renderIcon(EditIcon)
},
{
label: 'Delete',
key: 'delete',
icon: renderIcon(TrashIcon),
type: 'error'
},
{
type: 'divider',
key: 'd1'
},
{
label: 'More',
key: 'more',
children: [
{ label: 'Copy', key: 'copy' },
{ label: 'Move', key: 'move' }
]
}
]
const handleSelect = (key: string) => {
switch (key) {
case 'edit':
handleEdit()
break
case 'delete':
handleDelete()
break
}
}
</script>
<template>
<n-dropdown :options="options" @select="handleSelect">
<n-button>Actions</n-button>
</n-dropdown>
</template>Trigger on Right Click
<template>
<n-dropdown :options="options" trigger="contextmenu">
<div class="context-menu-area">
Right click here
</div>
</n-dropdown>
</template>
<style>
.context-menu-area {
height: 200px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
</style>Breadcrumb
Basic Usage
<script setup lang="ts">
const breadcrumbOptions = [
{ label: 'Home', to: '/' },
{ label: 'Products', to: '/products' },
{ label: 'Category', to: '/products/category' },
{ label: 'Item Detail' }
]
</script>
<template>
<n-breadcrumb>
<n-breadcrumb-item
v-for="item in breadcrumbOptions"
:key="item.label"
:href="item.to"
>
{{ item.label }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>With Router
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const breadcrumbs = computed(() => {
const matched = route.matched
return matched.map(route => ({
label: route.meta?.title || route.name,
to: route.path
}))
})
const handleClick = (path: string) => {
router.push(path)
}
</script>
<template>
<n-breadcrumb>
<n-breadcrumb-item
v-for="(item, index) in breadcrumbs"
:key="index"
:clickable="!!item.to"
@click="item.to && handleClick(item.to)"
>
{{ item.label }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>Tabs
Warning:n-tabswill extract the default tab value from its default slot, triggering a Vue slot warning in certain situations. To avoid seeing the warning, explicitly provide adefault-valueprop on the component.
Basic Usage
<script setup lang="ts">
import type { TabsInst } from 'naive-ui'
const tabsRef = ref<TabsInst | null>(null)
const activeTab = ref('users')
const panes = [
{ name: 'users', tab: 'Users', component: UserList },
{ name: 'roles', tab: 'Roles', component: RoleList },
{ name: 'permissions', tab: 'Permissions', component: PermissionList }
]
const addTab = () => {
panes.push({ name: 'new', tab: 'New Tab', component: NewComponent })
}
const removeTab = (name: string) => {
const index = panes.findIndex(p => p.name === name)
if (index > -1) {
panes.splice(index, 1)
}
}
</script>
<template>
<n-tabs
v-model:value="activeTab"
type="card"
closable
@close="removeTab"
>
<n-tab-pane
v-for="pane in panes"
:key="pane.name"
:name="pane.name"
:tab="pane.tab"
>
<component :is="pane.component" />
</n-tab-pane>
</n-tabs>
<n-button @click="addTab">Add Tab</n-button>
</template>Dynamic Tabs
<script setup lang="ts">
interface TabItem {
key: string
label: string
component: Component
props?: Record<string, any>
}
const tabs = ref<TabItem[]>([
{ key: 'home', label: 'Home', component: HomeView }
])
const activeKey = ref('home')
const openTab = (item: TabItem) => {
// Check if tab already exists
const existing = tabs.value.find(t => t.key === item.key)
if (!existing) {
tabs.value.push(item)
}
activeKey.value = item.key
}
const closeTab = (key: string) => {
const index = tabs.value.findIndex(t => t.key === key)
if (index > -1) {
tabs.value.splice(index, 1)
// Switch to another tab if closing active
if (activeKey.value === key && tabs.value.length > 0) {
activeKey.value = tabs.value[Math.min(index, tabs.value.length - 1)].key
}
}
}
</script>
<template>
<n-tabs
v-model:value="activeKey"
type="card"
closable
@close="closeTab"
>
<n-tab-pane
v-for="tab in tabs"
:key="tab.key"
:name="tab.key"
:tab="tab.label"
>
<component :is="tab.component" v-bind="tab.props" />
</n-tab-pane>
</n-tabs>
</template>PageHeader
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const handleBack = () => {
router.back()
}
</script>
<template>
<n-page-header
title="User Details"
subtitle="User ID: 12345"
@back="handleBack"
>
<template #avatar>
<n-avatar src="https://example.com/avatar.png" />
</template>
<template #extra>
<n-space>
<n-button>Edit</n-button>
<n-button type="error">Delete</n-button>
</n-space>
</template>
<template #footer>
<n-breadcrumb>
<n-breadcrumb-item>Home</n-breadcrumb-item>
<n-breadcrumb-item>Users</n-breadcrumb-item>
<n-breadcrumb-item>Details</n-breadcrumb-item>
</n-breadcrumb>
</template>
</n-page-header>
</template>Layout Patterns
Admin Layout
<template>
<n-layout style="height: 100vh">
<n-layout-header bordered style="height: 64px; padding: 0 24px">
<n-space align="center" justify="space-between" style="height: 100%">
<div class="logo">Admin</div>
<n-space>
<n-badge :value="5">
<n-button quaternary circle>
<n-icon :component="NotificationsIcon" />
</n-button>
</n-badge>
<n-dropdown :options="userOptions">
<n-button quaternary>
<n-avatar size="small" src="/avatar.png" />
Admin
</n-button>
</n-dropdown>
</n-space>
</n-space>
</n-layout-header>
<n-layout has-sider style="height: calc(100vh - 64px)">
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
:native-scrollbar="false"
>
<n-menu :options="menuOptions" />
</n-layout-sider>
<n-layout-content style="padding: 24px">
<n-breadcrumb style="margin-bottom: 16px">
<n-breadcrumb-item>Home</n-breadcrumb-item>
<n-breadcrumb-item>Dashboard</n-breadcrumb-item>
</n-breadcrumb>
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</template>Key Types
import type {
MenuOption,
MenuInst,
DropdownOption,
DropdownGroupOption,
TabsInst,
TabPaneProps
} from 'naive-ui'Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Menu not highlighting | Key mismatch | Ensure menu key matches route name |
| Dropdown not closing | Manual control needed | Use show prop with on-clickoutside |
| Tabs content not updating | Component caching | Use :key on component or disable keep-alive |
| Breadcrumb not reflecting route | Static data | Use route.matched for dynamic breadcrumbs |
Modal & Dialog
Basic Modal
Prerequisite: If you want to create a modal usinguseModal, you need to wrap the calling component insiden-modal-providerand useuseModalto get the API.
<template>
<n-button @click="show = true">Open</n-button>
<n-modal v-model:show="show" title="Modal" preset="card">
Content
</n-modal>
</template>
<script setup lang="ts">
const show = ref(false)
</script>Form Modal
<template>
<n-button @click="openModal">Add User</n-button>
<n-modal
v-model:show="showModal"
preset="card"
title="Add User"
style="width: 500px"
:mask-closable="false"
@after-leave="resetForm"
>
<n-form
ref="formRef"
:model="form"
:rules="rules"
label-placement="left"
label-width="80"
>
<n-form-item path="name" label="Name">
<n-input v-model:value="form.name" />
</n-form-item>
<n-form-item path="email" label="Email">
<n-input v-model:value="form.email" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showModal = false">Cancel</n-button>
<n-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
Submit
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
const showModal = ref(false)
const submitting = ref(false)
const formRef = ref<FormInst | null>(null)
const form = ref({
name: '',
email: ''
})
const rules: FormRules = {
name: { required: true, message: 'Name is required' },
email: { required: true, type: 'email', message: 'Invalid email' }
}
const openModal = () => {
showModal.value = true
}
const resetForm = () => {
form.value = { name: '', email: '' }
formRef.value?.restoreValidation()
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitting.value = true
await createUser(form.value)
message.success('Created')
showModal.value = false
} finally {
submitting.value = false
}
}
</script>Dialog (Preset)
Prerequisite: If you want to use dialog, you need to wrap the calling component insiden-dialog-providerand useuseDialogto get the API.
<script setup lang="ts">
const dialog = useDialog()
const confirmDelete = () => {
dialog.warning({
title: 'Confirm Delete',
content: 'Are you sure you want to delete this?',
positiveText: 'Delete',
negativeText: 'Cancel',
onPositiveClick: async () => {
await deleteItem()
message.success('Deleted')
}
})
}
const showError = () => {
dialog.error({
title: 'Error',
content: 'Something went wrong',
positiveText: 'OK'
})
}
</script>Draggable Modal
<template>
<n-modal
v-model:show="show"
preset="card"
title="Draggable"
draggable
>
Content that can be dragged by the header
</n-modal>
</template>Navigation & Steps
Components for displaying multi-step processes, timelines, and navigation.
Steps
Basic Usage
<script setup lang="ts">
import { ref } from 'vue'
const current = ref(1)
const steps = [
{ title: 'I am the first', description: 'Description' },
{ title: 'I am the second', description: 'Description' },
{ title: 'I am the third', description: 'Description' }
]
</script>
<template>
<n-steps :current="current" vertical>
<n-step
v-for="(step, index) in steps"
:key="index"
:title="step.title"
:description="step.description"
/>
</n-steps>
</template>Horizontal Steps
<template>
<n-steps :current="1">
<n-step title="Account" description="Create account" />
<n-step title="Profile" description="Set up profile" />
<n-step title="Complete" description="Done" />
</n-steps>
</template>Clickable Steps
<template>
<n-steps v-model:current="current" :on-update:current="onUpdateCurrent">
<n-step title="Step 1" />
<n-step title="Step 2" />
<n-step title="Step 3" />
</n-steps>
</template>Status
<template>
<n-steps :current="2" status="error">
<n-step title="Done" description="Finished" />
<n-step title="Error" description="Something wrong" />
<n-step title="Pending" description="Waiting" />
</n-steps>
</template>Icon Steps
<script setup lang="ts">
import { Person, Airplane, Bag } from '@vicons/ionicons5'
</script>
<template>
<n-steps :current="1">
<n-step title="Account" :icon="Person" />
<n-step title="Shipping" :icon="Airplane" />
<n-step title="Payment" :icon="Bag" />
</n-steps>
</template>Size
<template>
<n-space vertical>
<n-steps :current="1" size="small">
<n-step title="Small" />
<n-step title="Step 2" />
</n-steps>
<n-steps :current="1" size="medium">
<n-step title="Medium" />
<n-step title="Step 2" />
</n-steps>
</n-space>
</template>Timeline
Basic Usage
<template>
<n-timeline>
<n-timeline-item content="Oops" time="2018-04-03 20:46" />
<n-timeline-item type="success" title="Success" content="Success content" time="2018-04-03 20:46" />
<n-timeline-item type="info" title="Info" content="Info content" time="2018-04-03 20:46" />
<n-timeline-item type="warning" title="Warning" content="Warning content" time="2018-04-03 20:46" />
<n-timeline-item type="error" title="Error" content="Error content" time="2018-04-03 20:46" />
</n-timeline>
</template>Horizontal Timeline
<template>
<n-timeline horizontal>
<n-timeline-item content="Step 1" />
<n-timeline-item content="Step 2" />
<n-timeline-item content="Step 3" />
</n-timeline>
</template>Custom Icon
<script setup lang="ts">
import { Checkmark } from '@vicons/ionicons5'
</script>
<template>
<n-timeline>
<n-timeline-item :icon="Checkmark" content="Completed" />
</n-timeline>
</template>Item Line Type
<template>
<n-timeline>
<n-timeline-item line-type="dashed" content="Dashed line" />
<n-timeline-item line-type="dotted" content="Dotted line" />
</n-timeline>
</template>Pagination
Basic Usage
<script setup lang="ts">
const page = ref(1)
const pageSize = ref(10)
const itemCount = ref(100)
</script>
<template>
<n-pagination
v-model:page="page"
v-model:page-size="pageSize"
:item-count="itemCount"
show-size-picker
:page-sizes="[10, 20, 50]"
/>
</template>Quick Jumper
<template>
<n-pagination
v-model:page="page"
:page-count="20"
show-quick-jumper
/>
</template>Simple Mode
<template>
<n-pagination
v-model:page="page"
:page-count="20"
simple
/>
</template>Disabled
<template>
<n-pagination disabled :page-count="10" />
</template>Slot Customization
<template>
<n-pagination v-model:page="page" :page-count="20">
<template #prefix="{ itemCount, startIndex, endIndex }">
Showing {{ startIndex }} to {{ endIndex }} of {{ itemCount }} items
</template>
<template #suffix>
items per page
</template>
</n-pagination>
</template>Anchor
Basic Usage
<template>
<n-anchor affix>
<n-anchor-link title="Basic" href="#basic" />
<n-anchor-link title="API" href="#api">
<n-anchor-link title="Props" href="#props" />
<n-anchor-link title="Events" href="#events" />
</n-anchor-link>
</n-anchor>
</template>Scroll Container
<script setup lang="ts">
const scrollContainer = ref<HTMLElement | null>(null)
</script>
<template>
<div ref="scrollContainer" style="height: 300px; overflow: auto">
<n-anchor :get-container="() => scrollContainer">
<n-anchor-link title="Section 1" href="#section1" />
<n-anchor-link title="Section 2" href="#section2" />
</n-anchor>
<div id="section1">Section 1 content...</div>
<div id="section2">Section 2 content...</div>
</div>
</template>Offset
<template>
<n-anchor :offset-top="64">
<n-anchor-link title="Section" href="#section" />
</n-anchor>
</template>Anchor (Back to Top)
<template>
<n-back-top :right="40" :bottom="40">
<n-button circle>
<template #icon>
<n-icon :component="ArrowUp" />
</template>
</n-button>
</n-back-top>
</template>Visibility Height
<template>
<n-back-top :visibility-height="300">
Back to top after scrolling 300px
</n-back-top>
</template>Common Patterns
Multi-step Form
<script setup lang="ts">
const currentStep = ref(0)
const formData = ref({
account: {},
personal: {},
payment: {}
})
const steps = [
{ title: 'Account', key: 'account' },
{ title: 'Personal', key: 'personal' },
{ title: 'Payment', key: 'payment' },
{ title: 'Review', key: 'review' }
]
const nextStep = () => {
if (currentStep.value < steps.length - 1) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--
}
}
</script>
<template>
<n-card>
<n-steps :current="currentStep" :status="currentStatus">
<n-step
v-for="step in steps"
:key="step.key"
:title="step.title"
/>
</n-steps>
<div style="margin: 24px 0">
<account-form v-if="currentStep === 0" v-model="formData.account" />
<personal-form v-if="currentStep === 1" v-model="formData.personal" />
<payment-form v-if="currentStep === 2" v-model="formData.payment" />
<review-form v-if="currentStep === 3" :data="formData" />
</div>
<n-space justify="end">
<n-button v-if="currentStep > 0" @click="prevStep">
Previous
</n-button>
<n-button
v-if="currentStep < steps.length - 1"
type="primary"
@click="nextStep"
>
Next
</n-button>
<n-button
v-else
type="primary"
@click="submit"
>
Submit
</n-button>
</n-space>
</n-card>
</template>Order Tracking
<template>
<n-timeline>
<n-timeline-item
v-for="(event, index) in orderHistory"
:key="index"
:type="event.type"
:title="event.title"
:content="event.description"
:time="event.timestamp"
/>
</n-timeline>
</template>Table with Pagination
<script setup lang="ts">
const loading = ref(false)
const data = ref([])
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
pagination.page = page
fetchData()
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize
pagination.page = 1
fetchData()
}
})
const fetchData = async () => {
loading.value = true
const result = await api.getList({
page: pagination.page,
pageSize: pagination.pageSize
})
data.value = result.data
pagination.itemCount = result.total
loading.value = false
}
</script>
<template>
<n-data-table
:columns="columns"
:data="data"
:loading="loading"
:pagination="pagination"
remote
/>
</template>Key Types
import type {
StepsProps,
StepProps,
TimelineProps,
TimelineItemProps,
PaginationProps,
AnchorProps,
AnchorLinkProps,
BackTopProps
} from 'naive-ui'Select Component
Basic Usage
<template>
<n-select
v-model:value="selected"
:options="options"
placeholder="Select option"
clearable
/>
</template>
<script setup lang="ts">
import type { SelectOption } from 'naive-ui'
const selected = ref<string | null>(null)
const options: SelectOption[] = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2', disabled: true },
{ label: 'Option 3', value: '3' }
]
</script>Multiple with Tags
<template>
<n-select
v-model:value="selected"
multiple
:options="options"
:max-tag-count="3"
:max-tag-placeholder="omitted => `+${omitted.length}`"
clearable
/>
</template>
<script setup lang="ts">
const selected = ref<string[]>([])
</script>Async Search
<template>
<n-select
v-model:value="selected"
filterable
remote
clearable
placeholder="Search user..."
:options="options"
:loading="loading"
@search="handleSearch"
/>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import type { SelectOption } from 'naive-ui'
interface User {
id: string
name: string
email: string
}
const selected = ref<string | null>(null)
const options = ref<SelectOption[]>([])
const loading = ref(false)
const handleSearch = debounce(async (query: string) => {
if (!query || query.length < 2) {
options.value = []
return
}
loading.value = true
try {
const users = await searchUsers(query)
options.value = users.map((u: User) => ({
label: `${u.name} (${u.email})`,
value: u.id
}))
} finally {
loading.value = false
}
}, 300)
</script>Custom Render
<template>
<n-select
v-model:value="selected"
:options="options"
:render-label="renderLabel"
:render-tag="renderTag"
/>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { NIcon, NTag, NTooltip } from 'naive-ui'
const renderLabel = (option) => {
return h('div', { style: 'display: flex; align-items: center; gap: 8px' }, [
h(NIcon, null, { default: () => h(option.icon) }),
h('span', null, option.label),
option.description && h('small', { style: 'color: #999' }, option.description)
])
}
const renderTag = ({ option, handleClose }) => {
return h(NTag, {
type: option.type || 'default',
closable: true,
onClose: handleClose
}, { default: () => option.label })
}
</script>Grouped Options
<script setup lang="ts">
const groupedOptions = [
{
type: 'group',
label: 'Group 1',
key: 'group1',
children: [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' }
]
},
{
type: 'group',
label: 'Group 2',
key: 'group2',
children: [
{ label: 'Option 3', value: '3' }
]
}
]
</script>Create New Options
<template>
<n-select
v-model:value="selected"
v-model:options="options"
filterable
tag
multiple
@create="handleCreate"
/>
</template>
<script setup lang="ts">
const selected = ref([])
const options = ref([
{ label: 'Existing', value: 'existing' }
])
const handleCreate = (label) => {
const newOption = { label, value: label }
options.value.push(newOption)
return newOption
}
</script>Select Tag + Clearable Does Not Remove Created Options
When using tag and clearable, clearing the value removes the selection but keeps dynamically created options in the dropdown.
The Problem:
<template>
<n-select
v-model:value="selectedValue"
v-model:options="options"
filterable
tag
clearable
/>
</template>
<script setup lang="ts">
import type { SelectOption } from 'naive-ui'
const selectedValue = ref<string | null>(null)
const options = ref<SelectOption[]>([
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' }
])
// User creates new option: "New Option"
// Then clicks clear
// Incorrect: Value is cleared but "New Option" still appears in dropdown
</script>Solution: Handle clear event to reset options
<template>
<n-select
v-model:value="selectedValue"
v-model:options="currentOptions"
filterable
tag
clearable
@clear="handleClear"
/>
</template>
<script setup lang="ts">
import type { SelectOption } from 'naive-ui'
const selectedValue = ref<string | null>(null)
// Original options (never modified)
const originalOptions: SelectOption[] = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' }
]
// Working copy
const currentOptions = ref<SelectOption[]>([...originalOptions])
const handleClear = () => {
// Correct: Reset to original options, removing temporary ones
currentOptions.value = [...originalOptions]
}
</script>Alternative: Track and clean temporary options
<script setup lang="ts">
import type { SelectOption } from 'naive-ui'
const selectedValue = ref<string | null>(null)
const options = ref<SelectOption[]>([...originalOptions])
const temporaryOptions = ref<Set<string>>(new Set())
const handleCreate = (label: string): SelectOption => {
const newOption = { label, value: label }
temporaryOptions.value.add(label)
return newOption
}
const handleClear = () => {
// Remove temporary options
options.value = options.value.filter(
opt => !temporaryOptions.value.has(opt.label as string)
)
temporaryOptions.value.clear()
}
</script>Alternative: Use controlled mode without v-model:options
<template>
<n-select
v-model:value="selectedValue"
:options="allOptions"
filterable
tag
clearable
@create="handleCreate"
@clear="handleClear"
/>
</template>
<script setup lang="ts">
const selectedValue = ref<string | null>(null)
const baseOptions = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' }
]
const createdOptions = ref<SelectOption[]>([])
const allOptions = computed(() => [
...baseOptions,
...createdOptions.value
])
const handleCreate = (label: string) => {
const newOption = { label, value: label }
createdOptions.value.push(newOption)
return newOption
}
const handleClear = () => {
createdOptions.value = []
}
</script>Note:
This is expected behavior - clear removes the value, not the options. If you want to clean up temporary options, you need to handle it manually.
Programmatically Close Select Dropdown
Need to close select dropdown after custom action (e.g., selecting in a custom render, or after external trigger).
The Problem:
<template>
<n-select
v-model:value="selected"
:options="options"
:render-label="renderLabel"
/>
</template>
<script setup lang="ts">
// User asks: "How to programmatically close the dropdown menu?"
// There's no `close()` method exposed directly
</script>Solution: Use blur() method
<template>
<n-select
ref="selectRef"
v-model:value="selected"
:options="options"
@update:value="handleValueUpdate"
/>
</template>
<script setup lang="ts">
import type { SelectInst } from 'naive-ui'
const selectRef = ref<SelectInst | null>(null)
const handleValueUpdate = () => {
// Close dropdown by blurring
selectRef.value?.blur() // Correct: Works!
}
// Or close programmatically from external action
const closeDropdown = () => {
selectRef.value?.blur()
}
</script>Alternative: Control with focus state
<template>
<n-select
v-model:value="selected"
:options="options"
:menu-props="{ onClickoutside: handleClickOutside }"
/>
</template>
<script setup lang="ts">
const handleClickOutside = () => {
// Handle click outside
}
</script>Note:
blur()is the recommended way to close dropdown- There's no
close()oropen()method exposed - For opening, use
focus()method
Related
- Select Component Guide
Selection Components
Components for selecting options and toggling states.
Radio
Basic Usage
<script setup lang="ts">
import { ref } from 'vue'
const value = ref('apple')
</script>
<template>
<n-radio-group v-model:value="value" name="fruit">
<n-space>
<n-radio value="apple">Apple</n-radio>
<n-radio value="banana">Banana</n-radio>
<n-radio value="orange">Orange</n-radio>
</n-space>
</n-radio-group>
</template>Radio Button
<template>
<n-radio-group v-model:value="value">
<n-radio-button value="daily">Daily</n-radio-button>
<n-radio-button value="weekly">Weekly</n-radio-button>
<n-radio-button value="monthly">Monthly</n-radio-button>
</n-radio-group>
</template>Radio Group Options
<script setup lang="ts">
import type { RadioGroupProps } from 'naive-ui'
const options: RadioGroupProps['options'] = [
{ value: 'beijing', label: 'Beijing' },
{ value: 'shanghai', label: 'Shanghai' },
{ value: 'guangzhou', label: 'Guangzhou', disabled: true }
]
const value = ref('beijing')
</script>
<template>
<n-radio-group v-model:value="value" :options="options" />
</template>Sizes
<template>
<n-space vertical>
<n-radio-group v-model:value="value" size="small">
<n-radio-button value="a">Small</n-radio-button>
<n-radio-button value="b">Small</n-radio-button>
</n-radio-group>
<n-radio-group v-model:value="value" size="medium">
<n-radio-button value="a">Medium</n-radio-button>
<n-radio-button value="b">Medium</n-radio-button>
</n-radio-group>
<n-radio-group v-model:value="value" size="large">
<n-radio-button value="a">Large</n-radio-button>
<n-radio-button value="b">Large</n-radio-button>
</n-radio-group>
</n-space>
</template>Checkbox
Basic Usage
<script setup lang="ts">
const checked = ref(false)
</script>
<template>
<n-checkbox v-model:checked="checked">
I agree to the terms
</n-checkbox>
</template>Checkbox Group
<script setup lang="ts">
const value = ref<string[]>(['apple'])
const options = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' }
]
</script>
<template>
<n-checkbox-group v-model:value="value" :options="options" />
</template>Indeterminate State
<script setup lang="ts">
const allChecked = ref(false)
const indeterminate = ref(false)
const value = ref<string[]>([])
const options = ['Apple', 'Banana', 'Orange']
const handleUpdateAll = (checked: boolean) => {
value.value = checked ? [...options] : []
indeterminate.value = false
}
watch(value, (val) => {
if (val.length === 0) {
allChecked.value = false
indeterminate.value = false
} else if (val.length === options.length) {
allChecked.value = true
indeterminate.value = false
} else {
indeterminate.value = true
}
})
</script>
<template>
<n-space vertical>
<n-checkbox
v-model:checked="allChecked"
:indeterminate="indeterminate"
@update:checked="handleUpdateAll"
>
Select All
</n-checkbox>
<n-divider />
<n-checkbox-group v-model:value="value">
<n-space>
<n-checkbox v-for="opt in options" :key="opt" :value="opt">
{{ opt }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-space>
</template>Grid Layout
<template>
<n-checkbox-group v-model:value="value">
<n-grid :y-gap="8" :cols="2">
<n-gi v-for="i in 6" :key="i">
<n-checkbox :value="String(i)">
Option {{ i }}
</n-checkbox>
</n-gi>
</n-grid>
</n-checkbox-group>
</template>Switch
Basic Usage
<script setup lang="ts">
const active = ref(false)
</script>
<template>
<n-switch v-model:value="active" />
</template>With Text/Round
<template>
<n-space>
<n-switch v-model:value="value" round />
<n-switch v-model:value="value" :round="false" />
</n-space>
</template>Custom Content
<template>
<n-switch v-model:value="value" size="large">
<template #checked>ON</template>
<template #unchecked>OFF</template>
</n-switch>
</template>Loading State
<template>
<n-switch v-model:value="value" :loading="loading" />
</template>Value Mapping
<script setup lang="ts">
// Map checked/unchecked to custom values
const value = ref('active')
</script>
<template>
<n-switch
v-model:value="value"
checked-value="active"
unchecked-value="inactive"
/>
</template>Slider
Basic Usage
<script setup lang="ts">
const value = ref(50)
</script>
<template>
<n-slider v-model:value="value" :step="10" />
</template>Range Slider
<script setup lang="ts">
const range = ref([20, 80])
</script>
<template>
<n-slider v-model:value="range" range :step="1" />
</template>Marks
<script setup lang="ts">
const marks = {
0: '0°C',
20: '20°C',
40: '40°C',
60: '60°C',
80: '80°C',
100: '100°C'
}
</script>
<template>
<n-slider v-model:value="value" :marks="marks" />
</template>Vertical
<template>
<n-slider v-model:value="value" vertical style="height: 200px" />
</template>Disabled/Tooltip
<template>
<n-slider v-model:value="value" disabled />
<n-slider v-model:value="value" :tooltip="false" />
</template>Rate
Basic Usage
<script setup lang="ts">
const value = ref(3)
</script>
<template>
<n-rate v-model:value="value" />
</template>Custom Icon
<script setup lang="ts">
import { Heart } from '@vicons/ionicons5'
</script>
<template>
<n-rate>
<n-icon :component="Heart" />
</n-rate>
</template>Half Star
<template>
<n-rate v-model:value="value" allow-half />
</template>Readonly
<template>
<n-rate readonly :default-value="4" />
</template>Custom Count
<template>
<n-rate v-model:value="value" :count="10" />
</template>Size & Color
<template>
<n-space vertical>
<n-rate size="small" />
<n-rate size="medium" />
<n-rate size="large" />
<n-rate color="#ffb347" />
</n-space>
</template>Color Picker
<script setup lang="ts">
const color = ref('#2080F0FF')
</script>
<template>
<n-color-picker v-model:value="color" />
</template>Alpha Channel
<template>
<n-color-picker v-model:value="color" :show-alpha="false" />
</template>Modes
<template>
<n-color-picker
v-model:value="color"
:modes="['hex', 'rgb', 'hsl']"
/>
</template>Transfer
Warning: Then-legacy-transfercomponent is deprecated. It won't receive new features and will be removed in the next major version. It's recommended to use the newn-transfercomponent.
Common Patterns
Settings Form
<template>
<n-form>
<n-form-item label="Notifications">
<n-switch v-model:value="settings.notifications" />
</n-form-item>
<n-form-item label="Theme">
<n-radio-group v-model:value="settings.theme">
<n-radio-button value="light">Light</n-radio-button>
<n-radio-button value="dark">Dark</n-radio-button>
<n-radio-button value="auto">Auto</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="Languages">
<n-checkbox-group v-model:value="settings.languages">
<n-space>
<n-checkbox value="en">English</n-checkbox>
<n-checkbox value="zh">Chinese</n-checkbox>
<n-checkbox value="ja">Japanese</n-checkbox>
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="Volume">
<n-slider v-model:value="settings.volume" :step="1" />
</n-form-item>
</n-form>
</template>Product Rating
<template>
<n-card title="Product Reviews">
<n-space align="center">
<span style="font-size: 48px; font-weight: bold">4.5</span>
<n-space vertical>
<n-rate readonly allow-half :default-value="4.5" />
<n-text depth="3">Based on 128 reviews</n-text>
</n-space>
</n-space>
<n-divider />
<n-space vertical>
<n-slider
v-model:value="ratingFilter"
range
:marks="{1: '1★', 3: '3★', 5: '5★'}"
:min="1"
:max="5"
:step="1"
/>
</n-space>
</n-card>
</template>Key Types
import type {
RadioProps,
RadioGroupProps,
CheckboxProps,
CheckboxGroupProps,
SwitchProps,
SliderProps,
RateProps,
ColorPickerProps
} from 'naive-ui'Tree & TreeSelect
Async Loading Tree
<template>
<n-tree
:data="treeData"
:load-data="handleLoadData"
remote
checkable
selectable
@update:checked-keys="handleCheck"
/>
</template>
<script setup lang="ts">
import type { TreeOption } from 'naive-ui'
const treeData = ref<TreeOption[]>([
{
label: 'Parent',
key: 'parent',
isLeaf: false
}
])
const handleLoadData = async (node: TreeOption) => {
const children = await fetchChildren(node.key as string)
node.children = children
}
const handleCheck = (keys: Array<string | number>) => {
console.log('Checked:', keys)
}
</script>Controlled Tree
<template>
<n-space>
<n-button @click="expandAll">Expand All</n-button>
<n-button @click="collapseAll">Collapse All</n-button>
<n-button @click="checkAll">Check All</n-button>
</n-space>
<n-tree
:data="data"
checkable
:expanded-keys="expandedKeys"
:checked-keys="checkedKeys"
:selected-keys="selectedKeys"
@update:expanded-keys="keys => expandedKeys = keys"
@update:checked-keys="keys => checkedKeys = keys"
/>
</template>
<script setup lang="ts">
import type { TreeOption } from 'naive-ui'
const expandedKeys = ref<Array<string | number>>([])
const checkedKeys = ref<Array<string | number>>([])
const selectedKeys = ref<Array<string | number>>([])
const data = ref<TreeOption[]>([])
const expandAll = () => {
expandedKeys.value = getAllKeys(data.value)
}
const collapseAll = () => {
expandedKeys.value = []
}
const getAllKeys = (nodes: TreeOption[]): Array<string | number> => {
const keys: Array<string | number> = []
const traverse = (items: TreeOption[]) => {
items.forEach(item => {
keys.push(item.key as string | number)
if (item.children) {
traverse(item.children as TreeOption[])
}
})
}
traverse(nodes)
return keys
}
</script>TreeSelect
<template>
<n-tree-select
v-model:value="selected"
:options="options"
checkable
clearable
:expand-on-click="false"
/>
</template>
<script setup lang="ts">
import type { TreeOption } from 'naive-ui'
const selected = ref<string | null>(null)
const options: TreeOption[] = [
{
label: 'Parent',
key: 'parent',
children: [
{ label: 'Child 1', key: 'child1' },
{ label: 'Child 2', key: 'child2' }
]
}
]
</script>Virtual Scroll Tree
<template>
<n-tree
:data="largeData"
virtual-scroll
:height="400"
:item-size="30"
checkable
/>
</template>Virtual List
Use n-virtual-list for rendering large lists efficiently by only rendering visible items.
Basic Usage
<script setup lang="ts">
import { ref } from 'v ue'
interface Item {
key: number
value: string
}
const items = ref<Item[]>(
Array.from({ length: 10000 }, (_, i) => ({
key: i,
value: `Item ${i}`
}))
)
</script>
<template>
<n-virtual-list
:items="items"
:item-size="42"
style="max-height: 400px"
>
<template #default="{ item }">
<div class="list-item">
{{ item.value }}
</div>
</template>
</n-virtual-list>
</template>
<style>
.list-item {
height: 42px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #eee;
}
</style>With DataTable
<template>
<n-data-table
:columns="columns"
:data="data"
:scroll-x="1000"
virtual-scroll
:max-height="400"
/>
</template>With Select
<template>
<n-select
v-model:value="selected"
:options="options"
:virtual-scroll="true"
:fallback-option="false"
/>
</template>With Tree
<template>
<n-tree
:data="treeData"
virtual-scroll
:height="400"
:item-size="32"
/>
</template>Dynamic Item Height
<script setup lang="ts">
const getItemHeight = (item: Item, index: number) => {
// Return different heights based on content
return item.isExpanded ? 80 : 40
}
</script>
<template>
<n-virtual-list
:items="items"
:item-size="getItemHeight"
style="max-height: 400px"
>
<template #default="{ item, index }">
<div :style="{ height: getItemHeight(item, index) + 'px' }">
{{ item.value }}
</div>
</template>
</n-virtual-list>
</template>Key Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | Array | [] | List of items to render |
item-size | `number \ | Function` | 0 |
key-field | string | 'key' | Unique key field in items |
visible-items | number | - | Number of visible items (auto-calculated if not set) |
Controlled manner & uncontrolled manner
A component's manner can be controlled or uncontrolled. Uncontrolled manner means you only listen to its change but not control its value. Controlled manner means you control the component's value.
Uncontrolled manner
In this situation, you don't set <n-input />'s value but only listen to its change. Component's value will be controlled by itself.
<n-input @update:value="handleUpdateValue" />Controlled manner
In the situation, you listen to component's value & change at the same time. If you don't update value, component's value won't be changed. Its value is controlled by you.
<n-input :value="value" @update:value="handleUpdateValue" />v-model
A component with v-model works in controlled manner, since v-model is the same as :model-value and @update:model-value.
Uncontrolled manner in naive-ui
Different library has different behavior on how to distinguish controlled or uncontrolled manner. In naive-ui, if value is undefined or not passed, it will be uncontrolled. That is to say, if you set a component's value to undefined won't clear it but transform it from controlled manner to uncontrolled manner. If you need to clear it, at most time you can use null.
Not only value
Any props pair like xxx & @update:xxx can work both in controlled manner or uncontrolled manner.
Experimental Features
[!WARNING]
Caveats
>
The following features are unstable. Use them if you really need and prepare to follow the API changes.
Use TuSimple Theme
<script>
import { TsConfigProvider, useDialog, useMessage } from '@naive-ui/tusimple-theme'
// danger typed api
const dialog = useDialog()
dialog.danger(...)
const message = useMessage()
message.danger(...)
</script>
<template>
<ts-config-provider>
<my-app />
</ts-config-provider>
</template>Configuring Fonts
Naive UI works with vfonts. You can use fonts from vfonts easily which includes general fonts and monospace fonts.
Just import fonts from in your app's entry file. Then it will work.
// entry js file of your app
// ...
// General Font
import 'vfonts/Lato.css'
// Monospace Font
import 'vfonts/FiraCode.css'
// then it works
// ...Note: Different fonts from vfonts have different font weights. If you want to use Lato or OpenSans you need to configure the global font weight of naive-ui.
<!-- configuring the global font weight -->
<n-config-provider :theme-overrides="{ common: { fontWeightStrong: '600' } }">
<app />
</n-config-provider>Change Global Fonts by Customizing Theme
If you don't use vfont and want change global fonts by customzing theme, you need to use n-global-style to achieve that. Components' font won't be responsive to theme-overrides without n-global-style.
Note: Make vfont work without n-global-style is a compromised design (I think this is a correct behavior). In the next major version, global reset style won't contain font related style. They'll be all put inside n-global-style component.
Import on Demand (Tree Shaking)
Naive UI supports tree shaking for components, locales and themes.
By default the component theme is light, locale is enUS, and no extra imports are needed.
For more info about theming, see Customizing Theme.
Import Directly
<script>
import { defineComponent } from 'vue'
import { NConfigProvider, NInput, NDatePicker, NSpace } from 'naive-ui'
// theme
import { createTheme, inputDark, datePickerDark } from 'naive-ui'
// locale & dateLocale
import { zhCN, dateZhCN } from 'naive-ui'
export default defineComponent({
components: {
NConfigProvider,
NInput,
NDatePicker,
NSpace
},
setup() {
return {
darkTheme: createTheme([inputDark, datePickerDark]),
zhCN,
dateZhCN
}
}
})
</script>
<template>
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
<n-space vertical>
<n-input />
<n-date-picker />
</n-space>
</n-config-provider>
</template>
<style>
body {
background: black;
}
</style>Auto Import
You can use the unplugin-auto-import plugin to automatically import APIs.
If you develop using SFC, you can use the unplugin-vue-components plugin to automatically import components on demand.The plugin will automatically parse the components used in the template and import the components.
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
// vite.config.ts
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: [
'vue',
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar'
]
}
]
}),
Components({
resolvers: [NaiveUiResolver()]
})
]
})Install on Demand Globally
import {
// create naive ui
create,
// component
NButton
} from 'naive-ui'
import { createApp } from 'vue'
const naive = create({
components: [NButton]
})
const app = createApp()
app.use(naive)After the installation, you can use the components you installed in SFC like this.
<template>
<n-button>naive-ui</n-button>
</template>Installation
Please note that naive-ui only supports Vue3. If you are using Vue2, you may look at other libraries.
pnpm
Use pnpm to install.
pnpm add -D naive-uiUMD
Please refer to Using UMD.
Fonts
pnpm add -D vfontsIcons
naive-ui recommends using xicons as icon library. s