Tutorial: build your first admin page
This tutorial takes you from an empty project to an admin page with a menu, a dashboard, a data table, a validated form, delete confirmation, and language/theme switching. The examples use Vue 3 + Vite; each step ends with notes for React and Angular.
Requirements: Node 20+, and one of: Vue ≥ 3.5.2, React ≥ 18, Angular ≥ 20.
1. Create the project and install thl-ui
npm create vite@latest my-admin -- --template vue-ts
cd my-admin
npm i @thl-ui/vue @thl-ui/styles vue-router@thl-ui/core, @thl-ui/locales, @thl-ui/tokens, @thl-ui/icons and @thl-ui/charts are installed automatically. You don't need Bootstrap, jQuery or Google Fonts: the CSS and the Open Sans font are all included in @thl-ui/styles.
React / Angular
npm i @thl-ui/react @thl-ui/styles react-router # React
npm i @thl-ui/angular @thl-ui/styles # Angular2. Register the plugin and CSS
src/main.ts:
import { createApp } from 'vue';
import ThlUI from '@thl-ui/vue';
import '@thl-ui/styles/thl-ui.css';
import App from './App.vue';
import { router } from './router';
createApp(App)
.use(router)
.use(ThlUI, {
locale: 'en', // default language: en · ja · vi · zh
theme: 'default', // one of 12 skins
colorMode: 'auto', // light · dark · auto (follows the OS)
persist: true, // remember the user's choices in localStorage
})
.mount('#app');Add the thl-body class to <body> in index.html to get the base font, background color and heading sizes:
<body class="thl-body">React / Angular
React: wrap the app in <ThlProvider config={{ locale: 'en', persist: true }}>. Angular: bootstrapApplication(App, { providers: [provideZonelessChangeDetection(), provideThlUI({ locale: 'en' })] }) and add node_modules/@thl-ui/styles/dist/thl-ui.css to styles in angular.json.
3. Page shell: menu, topbar, sidebar
The menu is an array of MenuItem. label can be an i18n key (translated automatically) or plain text.
src/menu.ts:
import type { MenuItem } from '@thl-ui/core';
export const menu: MenuItem[] = [
{ key: 'dash', label: 'menu.dashboard', icon: 'layout-dashboard', href: '/' },
{ key: 'users', label: 'Users', icon: 'users', href: '/users' },
{ key: 'settings', label: 'Settings', icon: 'settings', children: [
{ key: 'profile', label: 'Profile', href: '/settings/profile' },
] },
];src/App.vue:
<script setup lang="ts">
import { useRoute, RouterLink, RouterView } from 'vue-router';
import { ThlAppLayout } from '@thl-ui/vue';
import { menu } from './menu';
const route = useRoute();
const user = { name: 'An Nguyễn', role: 'Administrator', avatar: '/me.jpg' };
const Nodes = (p: { nodes: unknown }) => p.nodes;
</script>
<template>
<ThlAppLayout :menu="menu" :current-path="route.path" :user="user" brand="My Admin">
<!-- make the menu use RouterLink instead of <a href> (no full page reload) -->
<template #link="{ item, content, class: cls, current }">
<RouterLink :to="item.href" :class="cls" :aria-current="current ? 'page' : undefined"><Nodes :nodes="content" /></RouterLink>
</template>
<RouterView />
</ThlAppLayout>
</template>Out of the box you get: a topbar with search, language and light/dark toggles, and a user menu; a multi-level sidebar that highlights the active item; and a settings panel (the ⚙ button) where users can change skin, layout, color mode and language. Below 1170px the sidebar collapses automatically; below 768px it becomes a slide-out menu.
Available slots: brand, topbar-left, topbar-right, link, sidebar-footer, footer.
4. A dashboard page
Wrap each page in ThlPage (title + breadcrumb). Layout uses the 12-column thl-row / thl-col-* grid: below 768px every column stacks into a single column.
src/pages/Dashboard.vue:
<script setup lang="ts">
import { ThlPage, ThlInfoBox, ThlCard, ThlLineChart, ThlLabel } from '@thl-ui/vue';
const series = [{ name: 'Revenue', data: [12, 19, 14, 25, 22, 30] }];
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
</script>
<template>
<ThlPage title="Overview" :breadcrumb="[{ label: 'Home', href: '/' }, { label: 'Overview' }]">
<div class="thl-row">
<div class="thl-col-12 thl-col-sm-6 thl-col-lg-3">
<ThlInfoBox icon="shopping-cart" value="154" title="Orders">
<template #footer>Target <ThlLabel color="success" rounded>300</ThlLabel></template>
</ThlInfoBox>
</div>
<!-- …3 more boxes… -->
</div>
<div class="thl-row">
<div class="thl-col-12 thl-col-lg-8">
<ThlCard title="Revenue, last 6 months"><ThlLineChart :series="series" :labels="labels" smooth area /></ThlCard>
</div>
</div>
</ThlPage>
</template>Grid classes: thl-col-{1..12} (all sizes), thl-col-sm-* (≥ 768px), thl-col-md-* (≥ 1024px), thl-col-lg-* (≥ 1170px).
5. Data table with search, sorting and pagination
<script setup lang="ts">
import { ref } from 'vue';
import { ThlPage, ThlCard, ThlDataTable, ThlButton, ThlLabel, confirm, useToast } from '@thl-ui/vue';
const toast = useToast();
const users = ref([
{ id: 1, name: 'An', email: '[email protected]', role: 'admin' },
{ id: 2, name: 'Bình', email: '[email protected]', role: 'user' },
]);
const columns = [
{ key: 'name', title: 'Name', sortable: true },
{ key: 'email', title: 'Email' },
{ key: 'role', title: 'Role', sortable: true },
{ key: 'actions', title: '', searchable: false },
];
async function remove(row: { id: number; name: string }) {
if (!(await confirm({ title: `Delete ${row.name}?`, icon: 'warning', confirmVariant: 'danger' }))) return;
users.value = users.value.filter((u) => u.id !== row.id);
toast.success('Deleted');
}
</script>
<template>
<ThlPage title="Users">
<ThlCard>
<ThlDataTable :data="users" :columns="columns" row-key="id" selectable :page-size="10">
<!-- customize cells with the cell-{key} slot -->
<template #cell-role="{ value }">
<ThlLabel :color="value === 'admin' ? 'danger' : 'info'" rounded>{{ value }}</ThlLabel>
</template>
<template #cell-actions="{ row }">
<ThlButton size="sm" variant="danger" outline icon="trash-2" :aria-label="`Delete ${row.name}`" @click="remove(row)" />
</template>
</ThlDataTable>
</ThlCard>
</ThlPage>
</template>Large server-side datasets: add manual and :total="totalCount", then load data in the @query-change="({ page, pageSize, sort, search }) => …" event.
6. Form with validation
Error messages are translated into the current language. Errors only appear after the first Submit, then update as soon as the user edits.
<script setup lang="ts">
import { reactive } from 'vue';
import { ThlForm, ThlField, ThlInput, ThlSelect, ThlButton, ThlFormActions, rules, useToast } from '@thl-ui/vue';
const toast = useToast();
const v = reactive({ name: '', email: '', role: null as string | null });
const roles = [{ label: 'Admin', value: 'admin' }, { label: 'Users', value: 'user' }];
const save = (ok: boolean) => ok && toast.success('Saved');
</script>
<template>
<ThlForm :values="v" @submit="save">
<ThlField label="Full name" name="name" :value="v.name" :rules="[rules.required(), rules.minLength(2)]" required v-slot="{ id }">
<ThlInput :id="id" v-model="v.name" />
</ThlField>
<ThlField label="Email" name="email" :value="v.email" :rules="[rules.required(), rules.email()]" required v-slot="{ id }">
<ThlInput :id="id" v-model="v.email" type="email" />
</ThlField>
<ThlField label="Role" name="role" :value="v.role" :rules="[rules.required()]" required>
<ThlSelect v-model="v.role" :options="roles" />
</ThlField>
<ThlFormActions>
<ThlButton type="submit" variant="primary">Save</ThlButton>
</ThlFormActions>
</ThlForm>
</template>Built-in rules: required, email, minLength(n), maxLength(n), min(n), max(n), number, pattern(re), match('fieldName'). Custom rules: a function that returns null (valid) or { key: 'i18n.key', params }.
7. Toasts and confirm dialogs
import { useToast, confirm } from '@thl-ui/vue';
const toast = useToast();
toast.success('Saved');
toast.error('Could not connect', { title: 'Error', duration: 0 }); // 0 = don't auto-close
if (await confirm({ title: 'Sign out?', icon: 'question' })) logout();AppLayout already mounts the toast and confirm dialog hosts. If you don't use AppLayout, place <ThlToaster /> and <ThlConfirmHost /> once at the app root.
8. Language, theme, color mode
Users can change these in the ⚙ panel. From code:
import { useThlConfig, useI18n } from '@thl-ui/vue';
const { set } = useThlConfig();
const { t, setLocale } = useI18n();
set({ theme: 'purple-dark', colorMode: 'dark', layout: 'horizontal' });
setLocale('en');
t('common.save'); // "Save"Add your own translation strings at setup:
app.use(ThlUI, {
locale: 'en',
messages: {
vi: { app: { welcome: 'Xin chào, {name}!' } },
en: { app: { welcome: 'Hello, {name}!' } },
},
});
// in a template: {{ $t('app.welcome', { name: user.name }) }}Change the brand color by overriding CSS variables, for example:
:root { --thl-theme: #7c3aed; --thl-on-theme: #fff; --thl-theme-text: #6d28d9; }See Theme & dark mode for all variables and how to keep sufficient contrast.
9. Login and error pages
Pages without a sidebar use their own layouts:
<ThlAuthLayout title="Sign in">
<ThlForm :values="v" @submit="login">…</ThlForm>
</ThlAuthLayout>
<ThlErrorLayout :code="404" /> <!-- title and description are translated automatically -->In the router, mark these routes so App.vue doesn't wrap them in ThlAppLayout (see meta.blank in apps/playground-vue).
10. Going to production
npm run build: Vite bundles the CSS and fonts automatically (only the font subsets you need are loaded).- Import only the components you use (
import { ThlButton } from '@thl-ui/vue') and install the plugin without global registration (app.use(ThlUI)→import { ThlUI } from '@thl-ui/vue') for a smaller bundle. - SSR (Nuxt, Next, Angular SSR) is supported: components don't touch
windowwhen rendering on the server.
Next steps
- Components: demos and API tables for every component.
- Global configuration: all options and per-component defaults.
- FAQ & troubleshooting.