The UmimaClean Stack, Part 11: Tailwind 4, Base UI, and Recharts

The frontend layer in detail - config-in-CSS and what disappeared with tailwind.config.js, why components are copied rather than installed, cva variants over prop soup, atomic-design folders that actually answer a question, and charts that theme themselves through CSS variables.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Three audiences, three shapes. Customers on phones. Staff on phones, in a van or at a counter. The owner on a desktop.

One component library has to cover all of it without becoming a pile of one-off screens. This part is how that layer is put together.

Tailwind 4: The Config File Is Gone

The headline change in Tailwind 4 is that tailwind.config.js does not exist. Configuration is CSS.

@import 'tailwindcss';
@import 'leaflet/dist/leaflet.css';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/roboto';

@custom-variant dark (&:is(.dark *));

@theme inline {
	--font-heading: var(--font-sans);
	--font-sans: 'Roboto Variable', sans-serif;

	--color-background: var(--background);
	--color-foreground: var(--foreground);
	--color-primary: var(--primary);
	--color-destructive: var(--destructive);
	--color-chart-1: var(--chart-1);
	/* ... */

	--radius-sm: calc(var(--radius) * 0.6);
	--radius-md: calc(var(--radius) * 0.8);
	--radius-lg: var(--radius);
	--radius-xl: calc(var(--radius) * 1.4);
}

:root {
	--background: oklch(1 0 0);
	--foreground: oklch(0.145 0 0);
	--primary: oklch(0.205 0 0);
	/* ... */
}

The build side is one Vite plugin (@tailwindcss/vite) and no JavaScript configuration at all.

Three things worth pulling out.

@theme inline maps design tokens to utilities. Declaring --color-primary generates bg-primary, text-primary, border-primary. The token is the API — no extend.colors object in a separate file that has to be kept in step with the CSS variables that actually drive it.

Colours are OKLCH. Perceptually uniform, which means oklch(0.6 0.15 250) and oklch(0.6 0.15 30) genuinely look equally bright, where the HSL equivalents do not. It matters for status badges, where six colours have to read as one set.

Radii are derived. One --radius, and every step is calc() from it. Change one number and the whole UI’s roundness moves together, in proportion. The alternative — six hardcoded pixel values — drifts the first time someone tweaks one in isolation.

What actually disappeared: the content array. Tailwind 4 finds template files automatically. That array was a recurring source of “why is this class not generating” for files in unusual locations, and it is gone.

Base UI, and Copied Components

inertia/components/ui/ holds 17 primitives — button, input, select, dialog, table, chart, accordion, calendar — generated by the shadcn CLI in the base-nova style, built on @base-ui/react.

{
	"$schema": "https://ui.shadcn.com/schema.json",
	"style": "base-nova",
	"rsc": false,
	"tsx": true,
	"tailwind": {
		"config": "",
		"css": "inertia/css/app.css",
		"baseColor": "neutral",
		"cssVariables": true
	},
	"iconLibrary": "tabler",
	"aliases": {
		"components": "@/components",
		"utils": "@/lib/utils",
		"ui": "@/components/ui"
	}
}

Note "config": "" — Tailwind 4, no config file to point at.

Base UI is unstyled behaviour. Focus management, keyboard navigation, ARIA attributes, portal handling, click-outside — the parts of a component that are genuinely hard and that everyone gets wrong when they build a select from a div. It ships no opinion about how anything looks.

The components are copied, not imported. button.tsx is a file in the repo. There is no @shadcn/button package.

This felt wrong before it felt right. The upside is total: customising a button is editing a file. No !important overrides, no fighting specificity, no waiting for a maintainer to expose a prop, no breaking change on a minor version bump.

The downside is real and worth naming: you now own 17 components. No upstream bug fixes, no automatic accessibility improvements. In practice they are thin — behaviour is in Base UI, and these files are mostly class strings — so ownership is cheap. It would not be for a component library with real logic in it.

Variants Instead of Prop Soup

import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
	'group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0',
	{
		variants: {
			variant: {
				default: 'bg-primary text-primary-foreground hover:bg-primary/80',
				outline: 'border-border bg-background hover:bg-muted hover:text-foreground',
				secondary:
					'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
				ghost: 'hover:bg-muted hover:text-foreground',
				destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
				link: 'text-primary underline-offset-4 hover:underline'
			},
			size: {
				default: 'h-8 gap-1.5 px-2.5',
				xs: 'h-6 gap-1 px-2 text-xs',
				sm: 'h-7 gap-1 px-2.5 text-[0.8rem]',
				lg: 'h-9 gap-1.5 px-2.5',
				icon: 'size-8',
				'icon-sm': 'size-7'
			}
		},
		defaultVariants: {
			variant: 'default',
			size: 'default'
		}
	}
)

function Button({
	className,
	variant = 'default',
	size = 'default',
	...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
	return (
		<ButtonPrimitive
			data-slot="button"
			className={cn(buttonVariants({ variant, size, className }))}
			{...props}
		/>
	)
}

class-variance-authority turns “which classes for which combination” into a declaration. VariantProps<typeof buttonVariants> derives the prop types from it, so variant="destrutive" is a compile error rather than an unstyled button.

The alternative is isPrimary, isSmall, isDanger booleans that can be combined into states nobody designed.

cn() is the piece that makes className overrides work:

export function cn(...inputs: ClassValue[]) {
	return twMerge(clsx(inputs))
}

clsx handles conditionals; tailwind-merge resolves conflicts by understanding Tailwind. Pass className="px-6" to a button whose variant sets px-2.5 and twMerge drops the variant’s padding rather than emitting both and leaving it to CSS source order — which is unpredictable once classes are extracted in an order nobody controls.

Without it, className on a styled component works about 70% of the time, which is worse than not working.

data-slot attributes appear on every primitive, and they are how compound components target their own parts:

in-data-[slot=button-group]: rounded-lg;

A button inside a button group rounds differently, expressed in the button rather than by the group reaching in. That is Tailwind 4’s in-* variant, and it removes a class of parent-child coupling that used to need context or class injection.

Folders That Answer a Question

inertia/components/
  ui/          17 generated primitives — Base UI + cva
  atoms/       password_input, phone_input
  molecules/   stat_card, pagination, confirm_action, export_button, live_orders,
               page_header, image_slide
  organisms/   pinpoint_map, customer_lookup, item_fields, service_fields, static_map
  layouts/     admin, staff, customer, auth, default

Atomic design, and I do not much care about the taxonomy. What it buys is that “where does this new component go?” has an answer, and the answer is the same for everyone.

The working rule:

  • ui — generated, unstyled behaviour, no domain knowledge
  • atoms — a single input with one behaviour added
  • molecules — a few atoms doing one job
  • organisms — a self-contained feature with its own state
  • layouts — page chrome per role

PhoneInput is an atom: an input that knows Indonesian phone formatting. StatCard is a molecule: a card, a number, a label, an icon. PinpointMap is an organism: it holds state, talks to the geolocation API, and renders a map (Part 7).

Five layouts, one per role plus auth and a default. Inertia applies the default at the root (Part 4) and pages opt into their role layout:

<CustomerLayout title="Tambah Alamat" description="Tambahkan alamat penjemputan UmimaClean Anda">

Role separation in the layout is what lets the customer area be mobile-first and the admin area desktop-first without either compromising toward the other. A single responsive layout serving both would be a worse version of each.

Domain Constants, Keyed on Stored Values

The bridge between backend enums and frontend presentation:

/**
 * Badge colour per order status, keyed by the stored enum value.
 *
 * Everything now arrives as it is stored, so this and the label maps in
 * `app/enums` are looked up with the same key. Matching on the Indonesian
 * wording instead would work right up until somebody reworded one, at which
 * point every badge would quietly fall back to grey.
 */
export const orderStatusStyles: Record<string, string> = {
	[OrderStatus.PICKUP_SCHEDULED]: 'bg-gray-200 text-gray-700',
	[OrderStatus.IN_PICKUP]: 'bg-blue-100 text-blue-700',
	[OrderStatus.IN_INSPECTION]: 'bg-blue-100 text-blue-700',
	[OrderStatus.AWAITING_PAYMENT]: 'bg-amber-100 text-amber-700',
	[OrderStatus.IN_CLEANING]: 'bg-blue-100 text-blue-700',
	[OrderStatus.CLEANING_DONE]: 'bg-teal-100 text-teal-700',
	[OrderStatus.IN_DELIVERY]: 'bg-blue-100 text-blue-700',
	[OrderStatus.COMPLETED]: 'bg-green-100 text-green-700',
	[OrderStatus.CANCELLED]: 'bg-red-100 text-red-700'
}

This is the payoff from the values-not-labels rule in Part 5, stated from the frontend side. The badge map and the label map are keyed identically, so both are looked up with the same value.

The failure it avoids is the quiet kind: rewording a status would leave every badge falling through to no match. Not an error — just grey badges, everywhere, and nobody notices for a week.

The colour scheme encodes state rather than assigning nine arbitrary colours. Blue is “in progress” — four statuses share it, because for a customer glancing at a list they are the same kind of thing. Amber is “you need to do something”. Teal is “we need you to do something”. Green is done, red is cancelled, grey is not started.

Nine statuses, six colours, because six is what the eye can distinguish at a glance.

Charts

Recharts, wrapped in a generated chart.tsx:

const revenueChartConfig = {
	revenue: { label: 'Pendapatan', color: 'var(--chart-1)' }
}

const pickupChartConfig = {
	booked: { label: 'Terjadwal', color: 'var(--chart-2)' }
}
<ChartContainer config={revenueChartConfig} className="h-56 w-full">
	<AreaChart data={revenueTrend} margin={{ left: 4, right: 4 }}>
		{/* axes, grid */}
		<ChartTooltip content={<ChartTooltipContent />} />
		<Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" />
	</AreaChart>
</ChartContainer>

The config object does the work that is otherwise scattered: it names each series and gives it a colour, then ChartContainer emits CSS variables (--color-revenue) that the chart elements reference.

Which means chart colours come from the same --chart-1..5 tokens as everything else, defined in app.css. Change the palette and the charts follow. A hardcoded stroke="#3b82f6" would not.

h-56 w-full on the container and Recharts’ ResponsiveContainer inside — charts are on the admin dashboard, which is desktop-first but has to survive a phone.

Why Recharts. Composable and React-native: a chart is JSX, so conditionally rendering a series is an &&. D3 is more powerful and would mean writing rendering code; Chart.js is imperative and fights React’s lifecycle. For a small-business dashboard — an area chart, a bar chart, some stat tiles — Recharts is exactly enough.

The data arrives chart-ready from the server, gaps filled (Part 3):

export type SeriesPoint = {
	/** ISO date, `yyyy-MM-dd`. What the chart keys and sorts on. */
	date: string
	/** Short Indonesian day label, e.g. `28 Jul`. What the axis shows. */
	label: string
	total: number
}

Two representations of the same day: one to sort on, one to print. Deriving the label in the component would mean date formatting in the frontend and a second locale configuration to keep in sync.

The Small Dependencies

Tabler Icons — 5,000+ icons as tree-shakeable React components. iconLibrary: "tabler" in components.json means generated components use them too, so there is one icon set rather than two.

Sonner for toasts. Used for exactly the things that have no place on the page:

toast.error('Tidak dapat mengambil lokasi Anda. Pastikan izin lokasi diaktifkan.')

Note what is not a toast: form validation errors render inline under their field (Part 5), and flash messages come through Inertia’s shared props. Toasts are for client-side events with no field to attach to.

date-fns on the client, Luxon on the server. This looks like an accident and is not: Adonis and Lucid speak Luxon natively, and react-day-picker speaks date-fns. Two libraries, two environments, no overlap and no conversion layer.

Embla for the carousel on the marketing page. tw-animate-css for animation utilities.

A small helper worth showing, because it is the kind of thing that ends up copy-pasted into three components otherwise:

/**
 * Builds a wa.me link for an Indonesian mobile number.
 *
 * Numbers are stored the way people write them locally (`08123…`), but WhatsApp
 * only accepts international format, so the leading zero becomes the 62 country
 * code. Anything already in international form is left alone.
 */
export function whatsappUrl(phone: string) {
	const digits = phone.replace(/\D/g, '')
	const international = digits.startsWith('0') ? `62${digits.slice(1)}` : digits

	return `https://wa.me/${international}`
}

Storage format and display format differ, and the conversion lives in one function. Same principle as the Excel date boundary — convert once, at the edge.

What I Would Change

formatRupiah exists twice — once in app/utils/currency.ts for the server, once in inertia/lib/utils.ts for the client. Same Intl call, same options, two files. Small, and exactly the kind of duplication that drifts. A shared module both sides import would fix it.

Some page components are long. The staff inspection form is a lot of file. Splitting into organisms would help, and it is the sort of refactor that is easy to start and easy to over-do.

No dark mode, despite the tokens. @custom-variant dark is declared, the CSS variables have dark values, and dark: classes appear throughout the generated components — but nothing toggles it. Half-built, and either finishing it or removing the dead branches would be better than leaving it ambiguous.

Class strings in the primitives are long. That is the cost of Tailwind plus cva, and it is a real readability tax on button.tsx. I accept it because the alternative — a separate stylesheet — reintroduces the naming problem Tailwind exists to remove. But nobody should pretend those lines are pleasant to read.


Next: Part 12 — Auth, Shield, Limiter, and Drive, where a signed cookie has no server-side session table and a password reset still has to end every session.

© 2026 r3p.dev. All rights reserved.