@netragate/design-system
@netragate/design-system
Vue 3 component library with design tokens, Tailwind CSS 4, and light/dark mode support.
- npm:
@netragate/design-system - Repository: github.com/netragate/ds
- Playground: netragate.github.io/ds
Requirements
- Vue
^3.5.0(peer dependency) - Node.js 18+
@lucide/vue, clsx, and tailwind-merge are bundled as dependencies of this package — you do not need to install them separately.
Installation
npm install @netragate/design-system vue
Quick setup
1. Import styles
In your app entry point (e.g. main.ts):
import { createApp } from 'vue'
import App from './App.vue'
import '@netragate/design-system/styles.css'
createApp(App).mount('#app')
2. Use components
<script setup lang="ts">
import { Button, Input, FormField } from '@netragate/design-system'
</script>
<template>
<FormField :label="'Email'">
<Input v-model="email" :placeholder="'you@company.com'" />
</FormField>
<Button :variant="'primary'">Save</Button>
</template>
3. Dark mode (optional)
Add the dark class on <html> to use dark tokens:
<html lang="en" class="dark">
Or toggle at runtime:
document.documentElement.classList.toggle('dark', isDark)
Playground
The local playground (npm run dev) includes:
- Home — interactive cards by category (forms, layout, feedback, foundations)
- Library — catalog of 63 components with description, usage snippet, and Open playground when a demo exists
- Docs — installation, toasts, DataTable, dark mode
- 38 drawer demos — Button, IconButton, Link, Input, Textarea, Label, DateInput, TimeInput, FormField, DataTable, Layout, Dialog, etc.; controls update the Usage snippet in real time
- Showcase — composed demos (e.g. AI Chat), not exported as library components
- i18n — English and Portuguese (pt-BR)
Library subcomponents (e.g. TabPanel, AppLayout, PageSizeSelect) open the parent component demo via an internal alias map.
Forms
Input with icon (composition)
Input and FormField do not have an icon prop. Add icons via the FormField slot with a relative wrapper:
<script setup lang="ts">
import Mail from '@lucide/vue/dist/esm/icons/mail.mjs'
import { FormField, Input } from '@netragate/design-system'
</script>
<template>
<FormField :label="'Email'" :required="true">
<template #default="{ id }">
<div class="relative">
<Mail
:size="14"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input :id="id" v-model="email" :type="'email'" class="pl-9" />
</div>
</template>
</FormField>
</template>
Switch vs Toggle
| Component | Use |
|---|---|
Switch |
Pill on/off control (role="switch"), sizes sm / md |
Toggle |
Preference row: label + Switch (bordered setting row) |
Input, Textarea, DateInput, and Badge accept :size (sm / md / lg on inputs; Badge only sm | md).
Input type supports 'text' | 'email' | 'password' | 'search' | 'date' | 'file'. For uploads, use :type="'file'" with optional accept / multiple. v-model receives the selected file name(s); read FileList from the native @change event.
<Input
v-model="fileName"
:type="'file'"
:accept="'.pdf,image/*'"
@change="(e) => { files = (e.target as HTMLInputElement).files }"
/>
DateInput
Date field with calendar. Display follows locale (en → mm/dd/yyyy, pt-BR → dd/mm/yyyy); persisted value is ISO YYYY-MM-DD.
:range="true"— start→end selection in the calendar ({ from, to }); opens two months side by side:show-time="true"— embedsTimeInput(HH:mm:ss); withrange, time is also a range:max-range-days="7"— inclusive max day span for range (omit /≤0= no limit); out-of-range days are disabled after the first click- In
rangemode, the footer uses Confirm (propconfirmLabel); the calendar closes only on confirm andv-modelupdates at that moment (selection and time stay pending until Confirm)
<DateInput v-model="date" :locale="'en'" />
<DateInput v-model="range" :range="true" :max-range-days="7" :locale="'en'" />
<DateInput v-model="dateTime" :show-time="true" :locale="'pt-BR'" />
TimeInput
HH:mm:ss (24h) time field, with optional :range for { from, to }. Labels follow locale; time format does not change by language.
<TimeInput v-model="time" />
<TimeInput v-model="timeRange" :range="true" />
Card
Single Card component with slots — there are no separate CardHeader / CardContent / CardFooter components:
<Card :variant="'outlined'">
<template #header>
<h3 class="font-semibold">Title</h3>
</template>
Main content.
<template #footer>
<Button :variant="'primary'">Save</Button>
</template>
</Card>
Variants: elevated | outlined | flat.
Table
Semantic table primitives: Table, TableHead, TableBody, TableRow, TableCell, and TableExpandedRow.
Stripe/hover styles use direct-child selectors so a nested Table inside a cell is not painted by the parent. Mark nested instances with :nested="true" (adds ds-table-nested).
Pattern A — nested table in a cell
<Table :striped="true">
<TableBody>
<TableRow>
<TableCell>Order #12</TableCell>
<TableCell>
<Table :nested="true">
<TableHead>
<TableRow>
<TableCell>Item</TableCell>
<TableCell>Qty</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Widget</TableCell>
<TableCell>2</TableCell>
</TableRow>
</TableBody>
</Table>
</TableCell>
</TableRow>
</TableBody>
</Table>
Pattern B — expandable detail row (composition)
<TableBody>
<TableRow>
<TableCell>Order #12</TableCell>
</TableRow>
<TableExpandedRow :colspan="1">
<Table :nested="true">…static child rows…</Table>
</TableExpandedRow>
</TableBody>
Prefer pattern B for longer child lists. Nested content MUST be static Table primitives — not a second full DataTable.
DataTable
Full table with global search, multi-sort (Ctrl+click / ⌘+click), column filters, pagination, and rows-per-page selector.
Columns
import type { DataTableColumn } from '@netragate/design-system'
const columns: DataTableColumn[] = [
{ key: 'name', label: 'Name', sortable: true, filter: 'text' },
{
key: 'status',
label: 'Status',
sortable: true,
filter: 'enum',
filterOptions: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
],
},
{ key: 'createdAt', label: 'Created', sortable: true, filter: 'date' },
]
Filter types: 'text' | 'date' (from/to) | 'enum' (multi-select). The filter icon in the header opens a popover per column.
Client-side usage
<script setup lang="ts">
import { DataTable, Lozenge } from '@netragate/design-system'
import type { DataTableColumnFilters, DataTableSortEntry } from '@netragate/design-system'
import { ref } from 'vue'
const columns = [/* ... */]
const rows = ref([/* ... */])
const page = ref(1)
const pageSize = ref(10)
const sortStack = ref<DataTableSortEntry[]>([])
const columnFilters = ref<DataTableColumnFilters>({})
</script>
<template>
<DataTable
v-model:sort-stack="sortStack"
v-model:column-filters="columnFilters"
v-model:page-size="pageSize"
v-model:current-page="page"
:columns="columns"
:rows="rows"
:row-key="'id'"
>
<template #cell-status="{ value }">
<Lozenge :variant="'success'">{{ value }}</Lozenge>
</template>
</DataTable>
</template>
- Click header: sort one column (
asc→desc→ clear) - Ctrl+click (⌘+click on Mac): add column to multi-sort (no limit)
- Filter: button beside sort on columns with
filterdefined - Resize: drag the right edge of a header (
resizable, defaulttrue).column.widthis the starting width. When a cell value is wider than the column, hover shows a tooltip with the full value.
Server-side usage
<DataTable
v-model:search="search"
v-model:current-page="page"
v-model:page-size="pageSize"
v-model:sort-stack="sortStack"
v-model:column-filters="columnFilters"
:columns="columns"
:rows="rows"
:total="total"
:loading="loading"
:server-side="true"
:row-key="'id'"
@request="loadFromApi"
/>
The @request event sends DataTableRequestParams (page, pageSize, search, sortStack, columnFilters).
With server-side enabled, column filters use an Apply button by default (columnFilterApply follows serverSide). Type in the popover and click Apply to fire @request — avoids reloading on every keystroke and losing input focus. For instant API filtering, pass :column-filter-apply="false".
Expandable rows (nested static table)
Set :expandable="true" for an accordion expand column (v-model:expanded-key — one open row at a time). Render a static nested Table in #expanded-row (not another DataTable).
expandMode="eager"(default): child data already on the row; expand shows#expanded-rowimmediately.expandMode="lazy": opening emitsexpandwith{ key, row }; set:expand-loading="true"while fetching — spinner appears only in the expand panel, not as the whole-table loading row.
<script setup lang="ts">
import { DataTable, Table, TableBody, TableCell, TableHead, TableRow } from '@netragate/design-system'
import { ref } from 'vue'
const expandedKey = ref<string | null>(null)
const expandLoading = ref(false)
const expandMode = ref<'eager' | 'lazy'>('eager')
async function onExpand({ key, row }: { key: string; row: Record<string, unknown> }) {
if (expandMode.value !== 'lazy') return
expandLoading.value = true
// await fetch children for row…
expandLoading.value = false
}
</script>
<template>
<DataTable
v-model:expanded-key="expandedKey"
:expandable="true"
:expand-mode="expandMode"
:expand-loading="expandLoading"
:columns="columns"
:rows="rows"
:row-key="'id'"
@expand="onExpand"
>
<template #expanded-row="{ row }">
<Table :nested="true">
<TableHead>
<TableRow>
<TableCell>Project</TableCell>
<TableCell>Role</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow v-for="item in (row.projects as { name: string; role: string }[])" :key="item.name">
<TableCell>{{ item.name }}</TableCell>
<TableCell>{{ item.role }}</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
</DataTable>
</template>
Pattern A (mini-table in a cell) still works via #cell-{key} with a nested Table :nested="true".
Toasts
Mount ToastHost once in the root layout and trigger toasts via the composable:
<script setup lang="ts">
import { ToastHost, useToast } from '@netragate/design-system'
const toast = useToast()
function onSave() {
toast.success('Changes saved.')
}
</script>
<template>
<ToastHost />
<Button :variant="'primary'" @click="onSave">Save</Button>
</template>
Available positions: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right.
toast.error('Failed to save.', {
position: 'bottom-center',
dismissible: true,
})
Variants: success | error | info | warning — opaque semantic background with matching border.
Overlay
Popover and Tooltip
Both use :variant (not appearance):
| Variant | Look |
|---|---|
outline |
Solid background (bg-popover) with neutral border — default |
primary |
Solid neutral background, no border |
ghost |
Translucent background with backdrop-blur |
danger |
Translucent destructive tint, light text |
<script setup lang="ts">
import { Button, Popover, Tooltip } from '@netragate/design-system'
</script>
<template>
<Popover :variant="'outline'">
<template #trigger>
<Button :variant="'outline'">Open</Button>
</template>
<template #content>
<p class="mb-2 text-sm font-medium">Quick actions</p>
<p class="text-xs opacity-80">Content anchored to the trigger.</p>
</template>
</Popover>
<Tooltip :content="'Helpful tip'" :variant="'ghost'" :placement="'top'">
<Button :variant="'outline'">Hover</Button>
</Tooltip>
</template>
Drawer
Sliding panel with :placement="'left' | 'right' | 'top' | 'bottom'" and optional :close-on-overlay="false".
Examples by category
Forms
<script setup lang="ts">
import {
Checkbox,
DateInput,
FormField,
Input,
Select,
Switch,
Toggle,
} from '@netragate/design-system'
import { ref } from 'vue'
const name = ref('')
const date = ref('')
const role = ref('')
const terms = ref(false)
const notifications = ref(true)
</script>
<template>
<FormField :label="'Name'" :required="true">
<Input v-model="name" />
</FormField>
<FormField :label="'Date'">
<DateInput v-model="date" :locale="'en'" />
</FormField>
<FormField :label="'Role'">
<Select
v-model="role"
:placeholder="'Select...'"
:options="[
{ label: 'Designer', value: 'design' },
{ label: 'Engineer', value: 'eng' },
]"
/>
</FormField>
<Checkbox v-model="terms">I accept the terms</Checkbox>
<Toggle v-model="notifications">Notifications</Toggle>
<Switch v-model="notifications" :size="'sm'" />
</template>
Feedback
<script setup lang="ts">
import { Alert, Badge, Progress, Skeleton, Spinner } from '@netragate/design-system'
</script>
<template>
<Alert :variant="'success'">Deploy complete.</Alert>
<Alert :variant="'error'" :dismissible="true">Build failed.</Alert>
<Badge :value="12" :variant="'primary'" :size="'md'" />
<Lozenge :variant="'success'">Active</Lozenge>
<Progress :value="72" />
<Skeleton class="h-8 w-full" />
<Spinner :aria-label="'Loading'" />
<!-- No glow: <Spinner :glow="false" :aria-label="'Loading'" /> -->
</template>
Layout
AppLayout organizes page regions (grid + side panel) but does not style header, menu, content, panel, or footer. Use the #menu slot with SidebarMenuItem / SidebarMenuGroup — the shell and chevron toggle are built in. With :settings-menu="true", pin the node whose id matches :settings-menu-id (default settings) at the menu footer — either a SidebarMenuGroup (flyout) or a standalone SidebarMenuItem (link). Use :submenu-mode to choose flyout vs inline expand for menu groups. See the Layout demo in the playground.
<script setup lang="ts">
import { ref } from 'vue'
import { AppLayout, Button, SidebarMenuGroup, SidebarMenuItem } from '@netragate/design-system'
const menuCollapsed = ref(false)
const panelOpen = ref(false)
const menuWidth = ref('12rem')
const menuCollapsedWidth = ref('3rem')
const menuLabel = ref('Navigation')
const activeId = ref('dashboard')
const openKeys = ref<string[]>([])
const settingsMenu = ref(true)
const settingsMenuId = ref('settings')
const submenuMode = ref<'flyout' | 'inline'>('flyout')
const pageTitle = ref('Dashboard')
</script>
<template>
<AppLayout
v-model:menu-collapsed="menuCollapsed"
v-model:panel-open="panelOpen"
v-model:active-menu-id="activeId"
v-model:open-menu-keys="openKeys"
class="min-h-svh"
:menu-width="menuWidth"
:menu-collapsed-width="menuCollapsedWidth"
:menu-label="menuLabel"
:settings-menu="settingsMenu"
:settings-menu-id="settingsMenuId"
:submenu-mode="submenuMode"
>
<template #header>
<div class="flex items-center justify-between border-b border-border bg-card px-4 py-3">
<h1 class="text-sm font-semibold text-foreground">My App</h1>
</div>
</template>
<template #menu>
<SidebarMenuItem :id="'dashboard'" :label="'Dashboard'" />
<SidebarMenuGroup :id="settingsMenuId" :label="'Settings'" :flyout-placement="'up'">
<SidebarMenuItem :id="`${settingsMenuId}.profile`" :label="'Profile'" />
</SidebarMenuGroup>
</template>
<div class="flex flex-col gap-4 p-6">
<p class="text-sm text-muted-foreground">{{ pageTitle }}</p>
<Button :variant="'outline'" :size="'sm'" @click="panelOpen = true">View details</Button>
</div>
<template #panel="{ closePanel }">
<div class="flex flex-col gap-4 border-l border-border p-6">
<button type="button" class="self-end text-xs text-muted-foreground hover:text-foreground" @click="closePanel()">
Close
</button>
</div>
</template>
<template #footer>
<div class="border-t border-border bg-card px-4 py-2 text-xs text-muted-foreground">
Footer
</div>
</template>
</AppLayout>
</template>
Submenu: :submenu-mode="'flyout'" (default) opens children on hover in a floating panel; :submenu-mode="'inline'" expands/collapses children below the group on click only. With a collapsed menu (rail) or settings group pinned at the footer, behavior stays flyout. active-menu-id marks the current page (ids with . or /); it does not open groups by itself — use v-model:open-menu-keys to pre-open the route path. Opening a group closes siblings at the same level (accordion). The menu list scrolls inside the viewport (ds-scrollbar class); settings stays fixed at the visible footer of the aside.
Settings footer: with :settings-menu="true", declare in #menu a SidebarMenuGroup or a standalone SidebarMenuItem whose id equals settings-menu-id (default settings). The id can be any string (e.g. admin.settings) — pin matches exact equality. Group → flyout on hover; single item → link in the footer. Do not declare both with the same id at once.
Sidebar menu IDs
- Top-level items outside a group should not share the group's prefix (avoid
todos.allnext to grouptodos— preferallor ids inside the group, e.g.todos.active). - Bind
v-model:active-menu-idto the route (or current page id) on setup so the active item is marked on reload. Inline groups start closed; they open only on click (or if you populatev-model:open-menu-keys, e.g. with the active id's ancestors). - With an empty
active-menu-id, the firstSidebarMenuItemis selected after one tick (gives the route time to fill the model). - Flyouts near the footer open upward — use
:flyout-placement="'up'"on the settingsSidebarMenuGroup('auto' | 'down' | 'up'; default'auto').
The default slot (content) fills 100% of available height — use class="min-h-svh" on AppLayout (or height: 100% on html, body, and #app). Header, panel, and footer are still composed by you with Tailwind classes.
Button icons
Variants: default | primary | secondary | outline | ghost | destructive | clean | link · sizes: default | sm | md | lg | icon.
appearance still works at runtime as a legacy alias (danger → destructive, primary → primary, etc.), but prefer :variant.
<script setup lang="ts">
import { Button } from '@netragate/design-system'
</script>
<template>
<Button :variant="'primary'" :size="'md'" :icon="'zap'">Action</Button>
</template>
Icons
~780 curated icon names via iconography / iconographyNames (metadata only). SVG components come from @lucide/vue (bundled dependency):
Button/Badge:iconprop — resolve by catalog name (e.g.:icon="'settings'"). Icons render instantly after the icon registry module loads.resolveIcon(name)— synchronous Vue component lookup for custom usage.loadIcon(name)is deprecated (returnsPromise.resolve(resolveIcon(name))).
Prefer :icon="'name'" on Button/Badge, or import individual icons from @lucide/vue/dist/esm/icons/<name>.mjs when you only need a few icons outside the catalog (never from '@lucide/vue' — that barrel loads every icon in dev).
3. Direct Lucide import — import Settings from '@lucide/vue/dist/esm/icons/settings.mjs' (or run npm run fix:lucide-imports in this repo after adding icons).
<script setup lang="ts">
import { IconButton, resolveIcon } from '@netragate/design-system'
const SettingsIcon = resolveIcon('settings')
</script>
<template>
<IconButton :aria-label="'Settings'" :variant="'outline'" :size="'icon'">
<SettingsIcon :size="16" />
</IconButton>
</template>
Legacy exports iconographyComponents / buttonIcons re-export the same eager registry.
Regenerate iconRegistry.bundle.js and iconFiles.ts after editing iconography.meta.ts: npm run generate:icons (also runs automatically before dev / build).
Exported components
| Category | Components |
|---|---|
| Actions | Button, IconButton, Link |
| Forms | Input, DateInput, TimeInput, Textarea, Checkbox, Radio, RadioGroup, Switch, Toggle, Select, Label, FormField |
| Feedback | Alert, Badge, Spinner, Progress, Skeleton, Toast, ToastHost |
| Navigation | Tabs, TabList, Tab, TabPanel, Breadcrumb, BreadcrumbItem, Pagination, SidebarMenu* |
| Data display | Card, Divider, Avatar, AvatarGroup, Lozenge, Table*, TableExpandedRow, DataTable, DataTableColumnFilter*, PageSizeSelect, List, ListItem, EmptyState |
| Overlay | Modal, Dialog, Tooltip, Popover, Drawer |
| Layout | Container, Stack, Grid, AppLayout |
| Utils | cn, useToast, buttonVariants, iconography, resolveIcon, loadIcon |
Pagination includes first/last page buttons (« »).
TypeScript
Types are included in the package — no separate @types needed.
import type {
ButtonIconName,
DataTableColumn,
DataTableColumnFilters,
DataTableExpandMode,
DataTableExpandPayload,
DataTableRequestParams,
DataTableSortEntry,
SelectOption,
SortDirection,
ToastPosition,
} from '@netragate/design-system'
Local development
npm install
npm run dev
Playground at http://localhost:5173.
npm run build:lib # library → dist/
npm run build:playground # static playground (GitHub Pages)
npm run test # Vitest
License
MIT
Related Packages
Vue 3 components cut from hexagons: 45° bevels drawn in CSS layers, an iridescen...
A professional Vue 3 component library with modern design principles
Accessible, reusable Vue 3 components and composables — a source-first design sy...
The design system behind Hikarinagi: Vue 3 components built on Tailwind CSS v4.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.14.2 | ^3.5.0 | MIT | |
| 0.14.0 | ^3.5.0 | MIT | |
| 0.13.1 | ^3.5.0 | MIT | |
| 0.12.4 | ^3.5.0 | MIT | |
| 0.12.2 | ^3.5.0 | MIT | |
| 0.12.1 | ^3.5.0 | MIT | |
| 0.11.0 | ^3.5.0 | MIT | |
| 0.10.1 | ^3.5.0 | MIT | |
| 0.10.0 | ^3.5.0 | MIT | |
| 0.9.7 | ^3.5.0 | MIT | |
| 0.9.4 | ^3.5.0 | MIT | |
| 0.9.3 | ^3.5.0 | MIT | |
| 0.9.0 | ^3.5.0 | MIT | |
| 0.8.3 | ^3.5.0 | MIT | |
| 0.8.0 | ^3.5.0 | MIT | |
| 0.7.10 | ^3.5.0 | MIT | |
| 0.7.9 | ^3.5.0 | MIT | |
| 0.7.8 | ^3.5.0 | MIT | |
| 0.7.3 | ^3.5.0 | MIT |