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 buttonsCSS variable in frontend/src/styles/globals.css (see Theming)
Variant or size of a buttoncva() block in frontend/src/components/ui/button.tsx
Layout of an existing sectionThe section file in frontend/src/components/sections/
Fields rendered by a sectionSection file + Directus collection fields (see below)
Marketing copy on a sectionEdit the row in Directus admin (or fallback in frontend/src/config/ui/pages.ts)
Header nav linksDirectus navigation collection (or fallback in pages.ts)
Footer link groupsDirectus footer_groups + footer_links collections
Add a new shadcn primitivepnpm dlx shadcn@latest add <name>
Add a brand-new section typeNew 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:

  1. Receives data via props (or fetches it via a helper from frontend/src/lib/directus.ts).
  2. 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:

  1. Add the field to directus/scripts/bootstrap.mjs under the target collection. Decide if it's translatable (lives in *_translations) or not (lives on the parent).
  2. Re-run the bootstrap to apply the schema change:
    docker compose run --rm directus-init
    
  3. Add the field to the TypeScript type in frontend/src/types/directus.ts.
  4. Update the GraphQL fetcher in frontend/src/lib/directus.ts to request the new field.
  5. 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):

  1. Add it to frontend/src/config/ui/pages.ts under the relevant section's fallback object.
  2. 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 in page_sections.payload JSON, OR create a dedicated resources collection.
  • 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:

  1. Create frontend/src/components/shared/BannerAnnouncement.tsx.
  2. Mark it "use client" if it needs interactivity.
  3. Mount it in frontend/src/app/[locale]/layout.tsx so it renders across all locale pages.
  4. If it needs CMS-driven content: add a field to site_settings (singleton) in bootstrap.mjs and read it via getSiteSettings().

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_submissions collection in bootstrap.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/revalidate via a Flow. Don't bypass the wrapper in frontend/src/lib/directus.ts or you lose cache busting.
  • Heavy interactive widgets (chat, lead magnet) are dynamically imported in layout.tsx: keep them lazy.

Verification after edits

  1. Visit / (or the page you edited) in en, fr, es.
  2. Toggle dark mode: confirm new components honor --background, --foreground, --primary.
  3. Open the Directus admin (http://localhost:8055), edit the corresponding row, refresh the frontend → confirm the change appears.
  4. If you added a Directus field, confirm it shows up in the admin form for that collection.

Related guides