Proper UI

Next.js integration

Set up a Next.js App Router or Pages Router project with Proper UI in minutes — providers, dark mode, and Tailwind v4 scanning included.

Proper UI ships as a regular npm workspace package, so a Next.js app consumes it the same way it consumes any other dependency — install it, import its styles, and wrap the tree in two providers.

Installation

The fastest path is the CLI. Run it from the root of your Next.js project:

npx @properui/cli@latest init --nextjs

It detects App Router vs Pages Router and whether you use a src/ directory automatically — --nextjs only overrides framework detection, it doesn't scaffold a new project. The CLI writes app/globals.css (or styles/globals.css for a Pages Router project), adds the Tailwind @source line so the package's classes get scanned, sets transpilePackages in next.config.ts, and wires up ThemeProvider in your root layout — it does not add RouterProvider for you, since that depends on which router you're using (see below). You're ready to add components once it's done.

Manual installation

Prefer to wire it up yourself, or already have opinions about your layout.tsx? Here's every step the CLI performs.

1. Install the package

npm install @properui/ui react-aria-components next-themes

2. Import the stylesheet

@properui/ui publishes a single globals.css that already contains the Tailwind import, the design tokens, typography, and every plugin the components need. Import it once and add an @source line so Tailwind v4 scans the package's own class names:

/* app/globals.css */
@import "@properui/ui/styles/globals.css";

@source "../node_modules/@properui/ui/src/**/*.{ts,tsx}";

3. Transpile the package

Next.js only compiles your own source by default. Add the package to transpilePackages so its TSX is processed the same way your app code is:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
    transpilePackages: ["@properui/ui"],
};

export default nextConfig;

4. Load a font with next/font

Proper UI's type scale reads from a --font-body (and --font-display) token in theme.css. Point them at whatever font next/font generates instead of loading a stylesheet:

// app/layout.tsx
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });

// …then: <html lang="en" suppressHydrationWarning className={inter.variable}>
/* app/globals.css, after the imports above */
@theme {
    --font-body: var(--font-inter), system-ui, sans-serif;
    --font-display: var(--font-inter), system-ui, sans-serif;
}

App router vs pages router

Both routers need the same two providers, but they're not interchangeable. ThemeProvider (from @properui/ui/providers, wrapping next-themes) works identically in both. RouterProvider (also from @properui/ui/providers) does not take a navigate prop — it reads useRouter from next/navigation internally, which only exists in the App Router. A Pages Router app has to use React Aria's own RouterProvider directly, fed from next/router.

App router

Both provider files already carry their own "use client" directive, so you can render them straight from the (server) root layout with no wrapper file of your own:

// app/layout.tsx
import { RouterProvider, ThemeProvider } from "@properui/ui/providers";
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en" suppressHydrationWarning>
            <body className="bg-primary text-primary antialiased">
                <ThemeProvider>
                    <RouterProvider>{children}</RouterProvider>
                </ThemeProvider>
            </body>
        </html>
    );
}

suppressHydrationWarning on <html> is required — next-themes writes the theme class before React hydrates, and without it React reports a mismatch. ThemeProvider forwards every next-themes prop, so defaultTheme, enableSystem, storageKey, and friends all still work if you want to override its defaults.

Pages router

The bundled RouterProvider doesn't work here — swap it for React Aria's own, fed next/router's useRouter:

// pages/_app.tsx
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { RouterProvider } from "react-aria-components";
import { ThemeProvider } from "@properui/ui/providers";
import "../styles/globals.css";

export default function App({ Component, pageProps }: AppProps) {
    const router = useRouter();

    return (
        <ThemeProvider>
            <RouterProvider navigate={router.push}>
                <Component {...pageProps} />
            </RouterProvider>
        </ThemeProvider>
    );
}

Using Proper UI components

Import each component from its own subpath — the same path the CLI writes when you run add:

import { Button } from "@properui/ui/components/base/buttons/button";

export default function Page() {
    return <Button size="md">Get started</Button>;
}

Most base components already carry their own "use client" directive because they're built on React Aria hooks, so you can import them directly into a server component's JSX without adding the directive yourself — the boundary is drawn inside the component, not at the call site. You only need "use client" on your own file when you add interactivity — a click handler, useState, a form — around the components you're composing.

Dark mode

Dark mode is class-based: Proper UI looks for .dark-mode on <html> and flips every semantic token underneath it. The value map passed to ThemeProvider above tells next-themes to write light-mode / dark-mode instead of its default light / dark class names, and suppressHydrationWarning on <html> stops React from complaining about the class next-themes adds before hydration.

Theme toggle

next-themes exposes the current theme through useTheme. Wire it to a button and you have a working toggle:

// components/theme-toggle-button.tsx
"use client";

import { useTheme } from "next-themes";
import { Button } from "@properui/ui/components/base/buttons/button";

export const ThemeToggleButton = () => {
    const { resolvedTheme, setTheme } = useTheme();

    return <Button onPress={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>Toggle theme</Button>;
};

FAQs