Introduction
GWAN is a high-performance component library and design system built for Next.js projects. It provides a curated set of production-ready components, a fully typed API, and a Tailwind CSS v4 theming layer — all with first-class dark mode support baked in.
GWAN is designed with a plug-and-play philosophy. Drop it into any Next.js project, configure your tokens once, and every component automatically adapts to your brand and colour scheme.
49+
Components
191+
Icons
v1.0.6
Version
MIT
License
Installation
Install the GWAN package and its peer dependencies into your existing Next.js project.
1. Install via npm
npm install gwan-design-system2. Install peer dependencies
GWAN requires React 19, Tailwind CSS v4, and next-themes for dark mode support.
npm install tailwindcss@^4 @tailwindcss/postcss next-themesTailwind Setup
GWAN uses Tailwind CSS v4 with the new @import "tailwindcss" syntax and the @theme directive. There is no tailwind.config.js required — all tokens live in your CSS file.
1. Configure PostCSS
Create or update postcss.config.mjs at the root of your project.
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;2. Configure content paths
Create tailwind.config.ts and point it at your source files.
// tailwind.config.ts
const config = {
content: [
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/templates/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
theme: {},
plugins: [],
};
export default config;3. Import Tailwind in globals.css
/* src/app/globals.css */
@import "tailwindcss";
/* Your theme tokens go here — see Theme Setup below */Theme Setup
GWAN's theming system is built entirely on CSS custom properties and Tailwind's @theme directive. You define your colour tokens once in globals.css — both a light and dark variant — and every component reads from those tokens automatically.
1. Define your tokens
Add the following to your globals.css. Customise the hex values to match your brand.
/* ─── Light theme ─────────────────────── */
:root {
--background: #f8f9f5;
--foreground: #1c2218;
--surface: #ffffff;
--surface-raised: #eef2e8;
--surface-overlay: #e3e9da;
--border: #c4cdb8;
--muted-fg: #60705a;
--primary-default: #435240;
--accent: #0f766e;
--success: #059669;
--danger: #e11d48;
--warning: #d97706;
}
/* ─── Dark theme ──────────────────────── */
.dark {
--background: #131a10;
--foreground: #e2eada;
--surface: #1c241a;
--surface-raised: #252e22;
--surface-overlay: #2d392a;
--border: #3c4d38;
--muted-fg: #9ea593;
--primary-default: #adc09e;
--accent: #2dd4bf;
--success: #34d399;
--danger: #fb7185;
--warning: #fbbf24;
}2. Register tokens with Tailwind
Use the @theme directive to expose every CSS variable as a Tailwind utility class.
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-surface-raised: var(--surface-raised);
--color-border: var(--border);
--color-muted-fg: var(--muted-fg);
--color-accent: var(--accent);
--color-success: var(--success);
--color-danger: var(--danger);
--color-warning: var(--warning);
/* Primary scale */
--color-primary-50: #f4f7f1;
--color-primary-100: #e2eada;
--color-primary-500: #9ea593;
--color-primary-800: #435240;
--color-primary-900: #2c3629;
}@theme, use these as normal Tailwind utilities — bg-background, text-foreground, border-border, bg-primary-500, etc.Dark Mode
GWAN uses next-themes to manage dark mode. The library toggles a .dark class on the <html> element, which switches the CSS custom properties you defined above.
1. Wrap your app with ThemeProvider
// src/app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}2. Add a theme toggle
Use the useTheme hook and guard against hydration mismatch with a mounted check.
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
);
}mounted before rendering theme-dependent UI. The server renders without knowing the user's preference, so reading theme before mount will produce a hydration mismatch.Using Components
Every GWAN component is a typed React functional component. Import directly from the package and pass the required props. Each component exposes an enum for its variant or type prop to ensure type safety.
Button
import Button, { BUTTON_VARIANTS } from "gwan-design-system/button";
<Button
variant={BUTTON_VARIANTS.PRIMARY}
label="Save Changes"
onClick={() => console.log("clicked")}
/>
<Button
variant={BUTTON_VARIANTS.SECONDARY}
label="Cancel"
onClick={() => {}}
disabled
/>Tag
import Tag, { TAG_TYPE } from "gwan-design-system/tag";
<Tag type={TAG_TYPE.SUCCESS} label="Completed" />
<Tag type={TAG_TYPE.DANGER} label="Failed" />
<Tag type={TAG_TYPE.WARNING} label="Pending" />
<Tag type={TAG_TYPE.INFO} label="In Transit" />Avatar
import Avatar, { AVATAR_VARIANT } from "gwan-design-system/avatar";
// With image
<Avatar
name="Alex Chon"
email="alex@gwan.dev"
image="/images/profile.png"
variant={AVATAR_VARIANT.IMAGE_WITH_FULL}
/>
// Initials only
<Avatar
name="Sam Rivera"
email="sam@gwan.dev"
variant={AVATAR_VARIANT.INITIALS_ONLY}
/>Input
import Input from "gwan-design-system/input";
const [value, setValue] = useState("");
<Input
label="Email address"
type="email"
value={value}
placeholder="you@example.com"
onChange={(e) => setValue(e.target.value)}
onClear={value ? () => setValue("") : undefined}
/>Snackbar
import Snackbar, { SNACK_BAR_TYPE } from "gwan-design-system/snackBar";
import { CheckSVG } from "gwan-design-system/icons";
<Snackbar
type={SNACK_BAR_TYPE.SUCCESS}
message="Profile updated successfully"
icon={<CheckSVG />}
/>Plug & Play
The real power of GWAN is how quickly you can assemble full UI surfaces. Below is a complete user profile card built entirely from GWAN components — no custom styles required.
"use client";
import { useState } from "react";
import Button, { BUTTON_VARIANTS } from "gwan-design-system/button";
import Avatar, { AVATAR_VARIANT } from "gwan-design-system/avatar";
import Tag, { TAG_TYPE } from "gwan-design-system/tag";
import Input from "gwan-design-system/input";
import Snackbar, { SNACK_BAR_TYPE } from "gwan-design-system/snackBar";
import { CheckSVG } from "gwan-design-system/icons";
export default function ProfileCard() {
const [name, setName] = useState("Alex Chon");
const [saved, setSaved] = useState(false);
const handleSave = () => {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
};
return (
<div className="bg-surface border border-border rounded-2xl p-6 flex flex-col gap-6 max-w-sm">
<Avatar
name={name}
email="alex@gwan.dev"
variant={AVATAR_VARIANT.INITIALS_WITH_FULL}
/>
<div className="flex gap-2">
<Tag type={TAG_TYPE.SUCCESS} label="Active" />
<Tag type={TAG_TYPE.INFO} label="Admin" />
</div>
<Input
label="Display name"
value={name}
onChange={(e) => setName(e.target.value)}
onClear={name ? () => setName("") : undefined}
/>
<Button
variant={BUTTON_VARIANTS.PRIMARY}
label="Save Changes"
icon={<CheckSVG />}
onClick={handleSave}
/>
{saved && (
<Snackbar
type={SNACK_BAR_TYPE.SUCCESS}
message="Profile saved successfully"
icon={<CheckSVG />}
/>
)}
</div>
);
}That is the full component. No utility class overrides, no theme wiring — it picks up light and dark mode automatically from the CSS variables you set up once.
Client vs Server Components
Most GWAN components are pure presentational components and work in both server and client contexts. A small number — those that use useState, useEffect, or browser APIs — must be used inside a "use client" boundary.
| Component | Server Safe | Reason |
|---|---|---|
| Accordion | ✗ Client | Uses useState for open/close state per item |
| Alert | ✗ Client | Uses useState for dismiss state |
| Avatar | ✗ Client | Uses useState + useEffect for pastel colour generation |
| AvatarGroup | ✗ Client | Uses useState for overflow tooltip visibility |
| Badge | ✓ Yes | Pure presentational |
| Banner | ✓ Yes | Pure presentational |
| Breadcrumb | ✓ Yes | Pure presentational |
| Button | ✓ Yes | Accepts onClick as a prop — no internal hooks |
| Callout | ✓ Yes | Pure presentational |
| Card | ✓ Yes | Pure presentational |
| Carousel | ✗ Client | Uses useState + useEffect for slide index and auto-play |
| Checkbox | ✓ Yes | Controlled — accepts onChange as a prop, no internal hooks |
| Chip | ✓ Yes | Accepts onClick as a prop — no internal hooks |
| CircularProgress | ✓ Yes | Pure presentational — SVG ring driven by value prop |
| ColorPicker | ✗ Client | Marked use client — uses useState for colour state |
| CommandPalette | ✗ Client | Marked use client — uses useState + keyboard listeners |
| ContextMenu | ✗ Client | Uses useState + useRef for cursor position and click-outside detection |
| DatePicker | ✗ Client | Marked use client — uses useState for calendar state |
| Divider | ✓ Yes | Pure presentational |
| Drawer | ✗ Client | Uses useEffect to lock body scroll |
| DropdownMenu | ✗ Client | Uses useState + useRef for dropdown state and click-outside detection |
| Ellipsis | ✗ Client | Uses useState for tooltip visibility |
| FileUploader | ✗ Client | Uses useRef to trigger the hidden file input |
| FilterDropdown | ✗ Client | Uses useState for dropdown visibility |
| Icons | ✓ Yes | Pure SVG components — no hooks |
| Input | ✓ Yes | Controlled — accepts onChange as a prop, no internal hooks |
| MenuBar | ✗ Client | Uses useState + ResizeObserver for overflow and mobile menu |
| Modal | ✗ Client | Uses useEffect + useRef for focus trap and scroll lock |
| MultiSelect | ✗ Client | Uses useState + useRef for dropdown state, search, and keyboard navigation |
| NavBar | ✗ Client | Uses useState + useEffect for collapse, tooltips, active item |
| Pagination | ✗ Client | Uses useState for page-size dropdown |
| Popover | ✗ Client | Marked use client — uses useState + click-outside detection |
| ProgressBar | ✓ Yes | Pure presentational |
| RadioButton | ✓ Yes | Controlled — accepts onChange as a prop, no internal hooks |
| SearchInput | ✗ Client | Uses useCallback + useEffect + useRef for debounced search |
| SelectDropdown | ✗ Client | Uses useState + useEffect + useRef for dropdown state |
| Skeleton | ✓ Yes | Pure presentational |
| Snackbar | ✓ Yes | Pure presentational — visibility controlled by consumer |
| Spinner | ✓ Yes | Pure presentational — CSS animation only |
| StatCard | ✓ Yes | Pure presentational |
| States | ✓ Yes | Pure presentational |
| Stepper | ✓ Yes | Pure presentational — step state controlled by consumer |
| Switch | ✓ Yes | Controlled — accepts onChange as a prop, no internal hooks |
| Table | ✓ Yes | Pure presentational — data and sorting controlled by consumer |
| Tabs | ✓ Yes | Controlled — accepts activeTab + onChange as props, no internal hooks |
| Tag | ✓ Yes | Pure presentational |
| TextArea | ✓ Yes | Controlled — accepts onChange as a prop, no internal hooks |
| TimeLine | ✗ Client | Uses useState + useEffect for animated step progression |
| Tooltip | ✓ Yes | Pure presentational — visibility controlled by consumer |
"use client" rather than marking the entire page client-side.Design Tokens
All tokens are CSS custom properties defined in :root and .dark. Overriding a token changes every component that references it — there is no cascading specificity to manage.
Semantic tokens
| Token | Light | Dark | Usage |
|---|---|---|---|
| --background | #f8f9f5 | #131a10 | Page background |
| --foreground | #1c2218 | #e2eada | Primary text |
| --surface | #ffffff | #1c241a | Cards, panels |
| --surface-raised | #eef2e8 | #252e22 | Elevated surfaces |
| --border | #c4cdb8 | #3c4d38 | All borders |
| --muted-fg | #60705a | #9ea593 | Secondary text |
| --accent | #0f766e | #2dd4bf | Links, highlights |
| --success | #059669 | #34d399 | Success states |
| --danger | #e11d48 | #fb7185 | Error states |
| --warning | #d97706 | #fbbf24 | Warning states |
Primary Colour Scale
The three semantic primary tokens (--primary-default, --primary-default-fg, --primary-muted) control the main brand colour at a high level. However, many components also reference fixed steps on the primary palette — primary-100 through primary-900 — for interactive states such as hover, focus rings, disabled backgrounds, and active fills.
These scale steps are registered once in @theme {} and do not change between light and dark mode. If you rebrand to a different hue, you must replace the entire scale so that button hover states, switch tracks, checkbox fills, stepper indicators, tabs, and progress bars all match your brand.
Components that use the primary scale
| Component | Scale steps used |
|---|---|
| Button | 100, 200, 300 (secondary); 500, 600, 700 (primary) |
| Tabs | 500 (active underline / pill) |
| Stepper | 500 (active & complete step, connector) |
| Switch | 500 (active track) |
| Progress Bar | 500 (default fill) |
| Radio Button | 500 (checked ring + fill) |
| Checkbox | 300, 400 (checked state) |
| Date Picker | 500 (selected date, today ring) |
Replacing the scale when rebranding
The Theme Generator includes a built-in scale builder — pick a seed colour and it auto-generates the full 10-step scale. Copy the output directly into your globals.css. To override manually, replace the values below.
/* In globals.css — replace ALL steps to match your brand hue */
@theme {
--color-primary-50: #f4f7f1; /* lightest tint */
--color-primary-100: #e2eada;
--color-primary-200: #c8d5be;
--color-primary-300: #adc09e;
--color-primary-400: #a4b496;
--color-primary-500: #9ea593; /* mid-point */
--color-primary-600: #7e8c73;
--color-primary-700: #60705a;
--color-primary-800: #435240;
--color-primary-900: #2c3629; /* darkest shade */
}Extending Themes
To rebrand GWAN for your project, override the CSS custom properties in your globals.css. You do not need to fork or patch the library — the components read whatever values you define.
Example: rebrand to an indigo palette
/* Override in your globals.css — after @import "tailwindcss" */
:root {
--background: #f5f5ff;
--foreground: #1a1a3e;
--surface: #ffffff;
--surface-raised: #ececff;
--border: #c7c7e8;
--muted-fg: #6464a0;
--primary-default: #4f46e5; /* indigo-600 */
--accent: #7c3aed; /* violet-600 */
}
.dark {
--background: #0f0f1a;
--foreground: #e0e0ff;
--surface: #16162a;
--surface-raised: #1e1e38;
--border: #2e2e60;
--muted-fg: #8080c0;
--primary-default: #818cf8; /* indigo-400 */
--accent: #a78bfa; /* violet-400 */
}
@theme {
--color-primary-500: #6366f1; /* your brand base */
}Adding custom component styles
Every component accepts a className prop for one-off overrides without touching the source.
<Button
variant={BUTTON_VARIANTS.PRIMARY}
label="Wide CTA"
onClick={() => {}}
className="w-full py-4 text-lg"
/>Versioning
GWAN follows Semantic Versioning (semver). The version is automatically injected at build time via the NEXT_PUBLIC_APP_VERSION environment variable, which reads from the latest Git tag.
# Tag a new release
git tag v0.2.0
git push origin v0.2.0
# The build script picks it up automatically:
# "prebuild": "echo NEXT_PUBLIC_APP_VERSION=$(git describe --tags --abbrev=0) > .env.local"You can display the current version in your UI anywhere:
<span className="text-muted-fg text-xs">
{process.env.NEXT_PUBLIC_APP_VERSION}
</span>© 2026 GWAN.DEV