UI Customization
This is the practical companion to UI Components. That page is the map of what's there. This page is how to change it: edit a primitive, add a new section, hook a section to Directus, swap a shared widget.
Layered editing model
| Want to change... | Edit |
|---|---|
| Color of all primary buttons | CSS variable in frontend/src/styles/globals.css (see Theming) |
| Variant or size of a button | cva() block in frontend/src/components/ui/button.tsx |
| Layout of an existing section | The section file in frontend/src/components/sections/ |
| Fields rendered by a section | Section file + Directus collection fields (see below) |
| Marketing copy on a section | Edit the row in Directus admin (or fallback in frontend/src/config/ui/pages.ts) |
| Header nav links | Directus navigation collection (or fallback in pages.ts) |
| Footer link groups | Directus footer_groups + footer_links collections |
| Add a new shadcn primitive | pnpm dlx shadcn@latest add <name> |
| Add a brand-new section type | New section component + new page_sections.type value (see below) |
Editing a primitive
The 9 UI primitives in frontend/src/components/ui/ are owned source: they're not in node_modules. Edit them like any TypeScript file.
Example: add a new xl size to button.tsx:
const buttonVariants = cva(
"inline-flex items-center justify-center ...",
{
variants: {
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
xl: "h-12 px-10 text-base", // new
},
},
},
);
Now <Button size="xl"> is available everywhere.
To restyle the visual appearance: change the Tailwind classes inside the variant string. To add a new variant entirely (e.g., a danger color): add it to the variant block and pass it like <Button variant="danger">.
Editing an existing section
Each section in frontend/src/components/sections/ is a server component that:
- Receives data via props (or fetches it via a helper from
frontend/src/lib/directus.ts). - Renders with primitives (
Section,Card,Button) using consistent spacing tokens.
To change the layout, edit the JSX directly. To change what data is shown, you have two paths:
Path A: Add a Directus field
If the new field should be editable in the CMS:
- Add the field to
directus/scripts/bootstrap.mjsunder the target collection. Decide if it's translatable (lives in*_translations) or not (lives on the parent). - Re-run the bootstrap to apply the schema change:
docker compose run --rm directus-init - Add the field to the TypeScript type in
frontend/src/types/directus.ts. - Update the GraphQL fetcher in
frontend/src/lib/directus.tsto request the new field. - Render it in the section component.
See Working with Directus for the field-add pattern in depth.
Path B: Use a fallback config
If the field is just a static label or a runtime tweak (no CMS editing needed):
- Add it to
frontend/src/config/ui/pages.tsunder the relevant section's fallback object. - Read it in the section component.
Adding a brand-new section
Say you want a new "Resources" section with cards linking to PDFs.
1. Decide the data source
- CMS-backed: add a new entry to
page_sections.type(e.g.,"resources") and store payload data inpage_sections.payloadJSON, OR create a dedicatedresourcescollection. - Static: just hardcode props.
2. Create the section component
// frontend/src/components/sections/ResourcesSection.tsx
import { Section } from "@/components/ui/section";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export interface Resource {
title: string;
description: string;
url: string;
}
interface ResourcesSectionProps {
title: string;
resources: Resource[];
}
export function ResourcesSection({ title, resources }: ResourcesSectionProps) {
return (
<Section>
<h2 className="text-3xl font-bold mb-8">{title}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{resources.map((r) => (
<a key={r.url} href={r.url} target="_blank" rel="noopener">
<Card className="hover:bg-accent transition-colors">
<CardHeader>
<CardTitle>{r.title}</CardTitle>
</CardHeader>
<CardContent>{r.description}</CardContent>
</Card>
</a>
))}
</div>
</Section>
);
}
3. Wire it into the page
In frontend/src/app/[locale]/page.tsx (or whichever page should render it), import and render with the data you fetched:
import { ResourcesSection } from "@/components/sections/ResourcesSection";
export default async function HomePage() {
// ... existing fetches
const resources = [/* from Directus or hardcoded */];
return (
<>
{/* existing sections */}
<ResourcesSection title="Resources" resources={resources} />
</>
);
}
4. Make it CMS-editable (optional)
Add a fetcher in frontend/src/lib/directus.ts:
export async function getResources(locale: Locale) {
const data = await directusGraphQL<{ resources: Resource[] }>(
`query ($locale: String!) {
resources {
url
translations(filter: { languages_code: { code: { _eq: $locale } } }) {
title
description
}
}
}`,
{ locale },
);
return flattenTranslations(data.resources);
}
Then read it in the page and pass to <ResourcesSection>.
Adding a shared widget
Shared widgets in frontend/src/components/shared/ are cross-page UI (analytics, cookie banner, lead magnet, theme toggle, etc.). To add a new one, e.g., a BannerAnnouncement:
- Create
frontend/src/components/shared/BannerAnnouncement.tsx. - Mark it
"use client"if it needs interactivity. - Mount it in
frontend/src/app/[locale]/layout.tsxso it renders across all locale pages. - If it needs CMS-driven content: add a field to
site_settings(singleton) inbootstrap.mjsand read it viagetSiteSettings().
Adding a shadcn primitive
The 9 shipped primitives don't cover everything. To add (for example) the slider primitive:
cd frontend
pnpm dlx shadcn@latest add slider
components.json at frontend/ controls the CLI config (component path, alias, base color). The generator drops frontend/src/components/ui/slider.tsx using your existing Tailwind tokens. No further wiring needed.
To gate which primitives buyers see: don't add them. The shipped 9 are a deliberate minimal set: extend only as needed.
Customizing form behavior
The contact form lives at frontend/src/components/contact/contact-page-client.tsx. It posts to a server action that writes to the form_submissions Directus collection.
Common edits:
- Add fields: add inputs in the JSX, add validation, add the field to the
form_submissionscollection inbootstrap.mjs, add the field to the server action's payload. - Change rate limit: edit
frontend/src/lib/rate-limit.ts(default: 5 submissions / 10 min per IP, in-memory). - Change submission target: replace the server action body to write somewhere else (e.g., post to Slack webhook, email via Resend).
Performance considerations
- All sections default to server components: they fetch in the same render and stream HTML. Don't add
"use client"unless you need state, effects, or browser APIs. - Section data fetchers are tagged for ISR cache invalidation: Directus writes trigger
/api/revalidatevia a Flow. Don't bypass the wrapper infrontend/src/lib/directus.tsor you lose cache busting. - Heavy interactive widgets (chat, lead magnet) are dynamically imported in
layout.tsx: keep them lazy.
Verification after edits
- Visit
/(or the page you edited) inen,fr,es. - Toggle dark mode: confirm new components honor
--background,--foreground,--primary. - Open the Directus admin (
http://localhost:8055), edit the corresponding row, refresh the frontend → confirm the change appears. - If you added a Directus field, confirm it shows up in the admin form for that collection.
Related guides
- UI Components: the catalog of what ships
- Working with Directus: adding fields, collections, and permissions
- Content Editing: editing content in the admin without touching code
- Theming: colors, fonts, dark mode