UI
The src/ui/ directory contains the presentation layer of saaskip. It is built on four pillars:
- Tailwind CSS 4 — utility-first styling with CSS variables for theming (OKLCH color space).
- shadcn/ui — component system using the base-lyra style. Unlike traditional libraries, components are copied into the project (
src/ui/components/shadcn/) and fully owned by the codebase. - Base UI — unstyled headless primitives (accessibility, keyboard navigation, ARIA). The foundation layer that shadcn components build on.
- Fonts — Inter for body text (
--font-sans) and Montserrat for headings (--font-heading), loaded vianext/font/googleinsrc/ui/fonts/.
Structure
src/ui/
components/ → app components (theme-toggle, shadcn primitives, dev tools)
helpers/ → cn() class merge utility
hooks/ → reusable hooks (useIsMounted, …)
fonts/ → Next.js font definitions
styles/ → globals.css (Tailwind + CSS variables)Icons live in src/config/icons.tsx — see Icons below.
Component system
Components are built on shadcn/ui using the base-lyra style, with Base UI as the headless primitive layer. Unlike traditional component libraries, shadcn components are copied into the project — they live in src/ui/components/shadcn/ and are fully owned by the codebase.
To add a new shadcn component:
pnpm dlx shadcn@latest add <component>Dark mode
Theme switching is powered by next-themes and integrated at the root layout via ThemeProvider.
How it works
ThemeProviderwraps the app insrc/app/layout.tsxwithattribute="class"anddefaultTheme="system".- next-themes adds a
darkclass to<html>based on the user's preference (or system preference on first visit). - CSS variables for both themes are defined in
src/ui/styles/globals.cssusing the OKLCH color space — the.darkselector overrides the:rootvalues. - The choice persists in
localStorageunder thethemekey and survives reloads.
ThemeToggle
The ThemeToggle component (src/ui/components/theme-toggle.tsx) is a button that switches between light and dark. It uses useSyncExternalStore to handle the mount/unmount cycle safely during SSR hydration.
import ThemeToggle from '@/ui/components/theme-toggle';
<ThemeToggle />
<ThemeToggle className="ml-auto" />CSS variables
Both themes define the same set of variables. To customize the palette, edit src/ui/styles/globals.css:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
/* … */
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
/* … */
}Helpers
cn
The cn helper merges class names using clsx and resolves Tailwind conflicts via tailwind-merge:
import { cn } from '@/ui/helpers';
cn('px-2 py-1', condition && 'bg-red-500', 'px-4');
// → 'py-1 bg-red-500 px-4' (px-2 overridden by px-4)Icons
Icons are centralized in src/config/icons.tsx via the ICONS registry and the icon() render function. Every component uses icon() instead of importing icon objects directly — this ensures the same icon is reused across the app and can be swapped in one place without touching component code.
import { icon } from '@/config/icons';
{
icon('themeDark', { className: 'size-4', 'aria-hidden': true });
}icon() accepts all standard SVG attributes (className, aria-hidden, strokeWidth, etc.).
To add a new icon, import it from @hugeicons/core-free-icons, add it to the ICONS registry, then call icon('myIcon', ...) everywhere.
To swap icon library, see Onboarding → Icons.
Hooks
useIsMounted
The useIsMounted hook (src/ui/hooks/use-is-mounted.ts) wraps useSyncExternalStore to safely detect whether the component is mounted on the client. It returns false during SSR and true after hydration, preventing flash-of-incorrect-state in components that depend on client-only APIs (theme, localStorage, etc.).
import { useIsMounted } from '@/ui/hooks/use-is-mounted';
const mounted = useIsMounted();
if (!mounted) return null;Storybook
Storybook 10 is configured with the Next.js + Vite framework. It provides an isolated environment for developing and visually testing UI components.
Commands
pnpm sb # start Storybook dev server (http://localhost:6006)
pnpm build:sb # build static Storybook bundleAddons
| Addon | Purpose |
|---|---|
@chromatic-com/storybook | Chromatic integration for visual regression |
@storybook/addon-vitest | Run component tests in Vitest |
@storybook/addon-a11y | Accessibility checks in the Storybook panel |
@storybook/addon-docs | MDX documentation pages |
@storybook/addon-mcp | MCP server for AI-driven Storybook access |
Writing stories
Stories are colocated with components using the .stories.tsx suffix. The glob pattern src/**/*.stories.@(js|jsx|mjs|ts|tsx) is configured in .storybook/main.ts.
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import MyComponent from './my-component';
const meta: Meta<typeof MyComponent> = {
title: 'Components/MyComponent',
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
export const Default: Story = {};Mocks
Storybook aliases server-only modules to lightweight mocks in .storybook/mocks/:
@/core/env→.storybook/mocks/env.ts@/core/i18n/navigation→.storybook/mocks/navigation.ts@/core/observability/logger→.storybook/mocks/logger.ts
Dev tools
Development-only components live in src/ui/components/dev/. They are gated by process.env.NODE_ENV !== 'development' and render nothing in production builds.
ScreenSize
The ScreenSize component (src/ui/components/dev/screen-size.tsx) is a floating breakpoint badge that displays the current Tailwind breakpoint (XS → 2XL). It is mounted once in the root layout and helps developers verify responsive behavior at a glance.
Features:
- Breakpoint label — shows the active breakpoint (XS, SM, MD, LG, XL, 2XL).
- Size — adjustable badge size (Small, Medium, Large).
- Position — dockable to any screen corner (Bottom Left, Bottom Right, Top Left, Top Right).
- Colored mode — color-codes the badge per breakpoint for quick visual identification.
- Persistence — preferences are stored in
localStoragevia a Zustand store (src/ui/components/dev/screen-size.store.ts) withpersistanddevtoolsmiddleware.