Skip to main content

Customizing Your Directory

This guide covers every layer of customization in the Ever Works Minimal Directory Template, from theming colors and fonts to creating entirely new components and data fields. The template is designed as a headless, unstyled canvas -- you control the final look through Tailwind CSS, component composition, and plugin configuration.

Prerequisites


1. Theming with Tailwind CSS

Where the styles live

The template uses Tailwind CSS v4 with the Vite plugin. There is no tailwind.config.js file -- configuration is done entirely in CSS using the @theme directive. The main style entry point is:

apps/web/src/styles/global.css

This file is imported by BaseLayout.astro and applies to every page.

How theming works

The template follows the shadcn/ui convention: CSS custom properties define your color palette, and the @theme inline block maps those properties to Tailwind color tokens.

/* apps/web/src/styles/global.css */
@import "tailwindcss";

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

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}

Changing colors

Edit the :root and .dark blocks in global.css. Colors use the oklch color space by default:

/* Light theme */
:root {
--background: oklch(1 0 0); /* white */
--foreground: oklch(0.145 0 0); /* near-black */
--primary: oklch(0.55 0.2 260); /* custom blue */
--primary-foreground: oklch(1 0 0); /* white text on primary */
--accent: oklch(0.85 0.1 140); /* soft green accent */
--accent-foreground: oklch(0.2 0.05 140);
--radius: 0.5rem; /* border radius base */
}

/* Dark theme */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.7 0.18 260);
--primary-foreground: oklch(0.1 0 0);
--accent: oklch(0.4 0.1 140);
--accent-foreground: oklch(0.95 0 0);
}

After editing, use Tailwind classes like bg-primary, text-accent-foreground, or border-border throughout your templates. Tailwind resolves these through the @theme mapping automatically.

Adding custom colors

To add a brand-specific color that is not part of the shadcn palette, extend the @theme inline block:

@theme inline {
/* ... existing mappings ... */
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
}

:root {
/* ... existing vars ... */
--brand: oklch(0.6 0.22 270);
--brand-foreground: oklch(1 0 0);
}

Now you can use bg-brand, text-brand-foreground, etc. in your components.

Changing fonts

Add a custom font via @theme inline:

@theme inline {
/* ... existing mappings ... */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}

Load the font in BaseLayout.astro via the <slot name="head"> mechanism or by adding a <link> directly:

<!-- In BaseLayout.astro <head> -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />

Changing spacing

The template sets a spacing base via --spacing in :root:

:root {
--spacing: 0.25rem; /* 4px base, so p-4 = 16px */
}

Tailwind v4 uses this as the foundation for all spacing utilities. Adjusting this value scales the entire spacing system proportionally.


2. Customizing Layouts

Understanding BaseLayout

Every page wraps its content in BaseLayout.astro, which lives at:

apps/web/src/layouts/BaseLayout.astro

It provides the HTML document shell, SEO meta tags (via plugin-seo), the SiteHeader, and the SiteFooter. Here is its structure:

---
import SiteHeader from '@ever-works/ui/astro/SiteHeader.astro';
import SiteFooter from '@ever-works/ui/astro/SiteFooter.astro';
import '../styles/global.css';

interface Props {
title: string;
description?: string;
config: SiteConfig;
canonicalUrl?: string;
ogImage?: string;
pageType?: 'website' | 'article' | 'product';
}

const { title, description, config } = Astro.props;
const navItems = [
{ label: 'Home', href: '/' },
{ label: 'Categories', href: '/categories' },
{ label: 'Tags', href: '/tags' },
{ label: 'Collections', href: '/collections' },
{ label: 'Comparisons', href: '/comparisons' },
];
---

<html lang="en" data-component="root">
<head>
<!-- meta tags, SEO, favicon -->
<slot name="head" />
</head>
<body data-component="body">
<SiteHeader config={config} nav={navItems} />
<main data-component="main">
<slot />
</main>
<SiteFooter config={config} />
</body>
</html>

Modifying the navigation

Edit the navItems array in BaseLayout.astro to add, remove, or reorder navigation links:

const navItems = [
{ label: 'Home', href: '/' },
{ label: 'Categories', href: '/categories' },
{ label: 'About', href: '/pages/about' },
{ label: 'Contact', href: '/pages/contact' },
];

The SiteHeader and SiteFooter components from @ever-works/ui are headless -- they render semantic HTML with data-component and data-part attributes. Style them with Tailwind by passing a class prop:

<SiteHeader
config={config}
nav={navItems}
class="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md"
/>

<SiteFooter
config={config}
class="mt-auto border-t border-border bg-muted py-8"
/>

Creating a new layout

If some pages need a different structure (e.g., a sidebar layout), create a new layout file:

---
// apps/web/src/layouts/SidebarLayout.astro
import BaseLayout from './BaseLayout.astro';
import type { SiteConfig } from '@ever-works/core';

interface Props {
title: string;
config: SiteConfig;
}

const { title, config } = Astro.props;
---

<BaseLayout title={title} config={config}>
<div class="mx-auto max-w-7xl px-4 py-8 lg:grid lg:grid-cols-[280px_1fr] lg:gap-8">
<aside class="hidden lg:block">
<nav class="sticky top-20 space-y-2">
<slot name="sidebar" />
</nav>
</aside>
<div>
<slot />
</div>
</div>
</BaseLayout>

Use it in a page:

---
import SidebarLayout from '../layouts/SidebarLayout.astro';
import { getContent } from '../lib/content';
const { config, categories } = await getContent();
---

<SidebarLayout title="Browse" config={config}>
<Fragment slot="sidebar">
{categories.map((cat) => (
<a href={`/category/${cat.id}`} class="block rounded-md px-3 py-2 text-sm hover:bg-accent">
{cat.name}
</a>
))}
</Fragment>

<!-- Main content goes here -->
<h1 class="text-3xl font-bold">Browse Items</h1>
</SidebarLayout>

3. Customizing Pages

Available pages

The template ships with these routes in apps/web/src/pages/:

RouteFilePurpose
/index.astroHome page
/page/[page]page/[page].astroPaginated listing
/item/[slug]item/[slug].astroItem detail
/categoriescategories.astroCategory index
/category/[slug]category/[slug].astroItems by category
/tagstags.astroTag index
/tag/[slug]tag/[slug].astroItems by tag
/collectionscollections.astroCollection index
/collection/[slug]collection/[slug].astroCollection detail
/comparisonscomparisons.astroComparison index
/comparison/[slug]comparison/[slug].astroComparison detail
/pages/[slug]pages/[slug].astroStatic content pages
/404404.astroNot found

Modifying an existing page

Pages import headless components from @ever-works/ui and data from getContent(). Here is the pattern:

---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getContent } from '../lib/content';
import ItemGrid from '@ever-works/ui/astro/ItemGrid.astro';
import Hero from '@ever-works/ui/astro/Hero.astro';

const { items, categories, config } = await getContent();
---

<BaseLayout title="Home" config={config}>
<Hero
title={config.company_name}
subtitle={`Browse ${items.length} ${config.items_name}`}
class="bg-gradient-to-br from-primary to-accent text-primary-foreground rounded-2xl px-8 py-16 text-center mb-12"
/>

<ItemGrid
items={items}
columns={3}
class="gap-6"
/>
</BaseLayout>

Components accept a class prop for Tailwind styling and data-* attributes for CSS hooks.

Creating a new page

Add an .astro file under apps/web/src/pages/. Astro uses file-based routing:

---
// apps/web/src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import { getContent } from '../lib/content';

const { config } = await getContent();
---

<BaseLayout title="About" config={config}>
<section class="mx-auto max-w-3xl px-4 py-12 prose dark:prose-invert">
<h1>About {config.company_name}</h1>
<p>
We curate the best {config.items_name.toLowerCase()} to help you
find exactly what you need.
</p>
</section>
</BaseLayout>

This creates the route /about.

Using component slots

Many UI components support named slots for composition:

---
import ItemCard from '@ever-works/ui/astro/ItemCard.astro';
import CategoryBadge from '@ever-works/ui/astro/CategoryBadge.astro';
import TagBadge from '@ever-works/ui/astro/TagBadge.astro';
---

<ItemCard item={item} class="rounded-xl border border-border p-6 hover:shadow-lg transition-shadow">
<CategoryBadge slot="category" category={category} class="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full" />
<Fragment slot="tags">
{item.tags.map((tag) => (
<TagBadge tag={{ id: tag, name: tag }} class="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded" />
))}
</Fragment>
<a slot="actions" href={item.source_url} class="text-sm font-medium text-primary hover:underline">
Visit Website &rarr;
</a>
</ItemCard>

4. Adding Custom Components

Astro components (static)

For content that does not need interactivity, create an Astro component:

---
// apps/web/src/components/StatsBar.astro
interface Props {
itemCount: number;
categoryCount: number;
class?: string;
}

const { itemCount, categoryCount, class: className } = Astro.props;
---

<div
class:list={['grid grid-cols-2 gap-4 rounded-xl border border-border bg-card p-6', className]}
data-component="stats-bar"
>
<div class="text-center">
<p class="text-3xl font-bold text-primary">{itemCount}</p>
<p class="text-sm text-muted-foreground">Items</p>
</div>
<div class="text-center">
<p class="text-3xl font-bold text-primary">{categoryCount}</p>
<p class="text-sm text-muted-foreground">Categories</p>
</div>
</div>

Use it in a page:

---
import StatsBar from '../components/StatsBar.astro';
---
<StatsBar itemCount={items.length} categoryCount={categories.length} class="mb-8" />

Zero JavaScript is shipped to the browser for Astro components.

Preact islands (interactive)

For components that need client-side interactivity, create a Preact component and hydrate it as an Astro island:

// apps/web/src/components/NewsletterSignup.tsx
import { useState } from 'preact/hooks';

interface Props {
class?: string;
}

export default function NewsletterSignup({ class: className }: Props) {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);

const handleSubmit = (e: Event) => {
e.preventDefault();
// Handle form submission
setSubmitted(true);
};

if (submitted) {
return (
<div class={`rounded-xl border border-border bg-card p-6 text-center ${className ?? ''}`}>
<p class="text-lg font-medium text-foreground">Thanks for subscribing!</p>
</div>
);
}

return (
<form
onSubmit={handleSubmit}
class={`flex gap-2 rounded-xl border border-border bg-card p-4 ${className ?? ''}`}
>
<input
type="email"
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
placeholder="[email protected]"
class="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm"
required
/>
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Subscribe
</button>
</form>
);
}

Use it in an Astro page with a hydration directive:

---
import NewsletterSignup from '../components/NewsletterSignup';
---

<!-- Hydrates when the element becomes visible -->
<NewsletterSignup client:visible class="mt-8" />

Hydration directives

DirectiveBehavior
client:loadHydrate immediately on page load (use for above-the-fold interactive elements like search)
client:visibleHydrate when the element enters the viewport (use for below-the-fold content)
client:idleHydrate when the browser is idle (use for low-priority features like back-to-top)
client:only="preact"Render only on the client, skip SSR entirely

Using cn() for class merging

The cn() utility from @ever-works/ui merges Tailwind classes with conflict resolution:

import { cn } from '@ever-works/ui/lib/utils';

interface Props {
variant?: 'default' | 'outline';
class?: string;
}

export default function MyButton({ variant = 'default', class: className }: Props) {
return (
<button
class={cn(
'rounded-lg px-4 py-2 text-sm font-medium transition-colors',
variant === 'default' && 'bg-primary text-primary-foreground hover:bg-primary/90',
variant === 'outline' && 'border border-input bg-background hover:bg-accent',
className
)}
>
Click me
</button>
);
}

5. Configuring Plugins

The plugins.config.ts file

All plugins are registered in a single file:

apps/web/src/lib/plugins.config.ts

The template uses definePlugins() to declare which plugins are active:

import { definePlugins } from '@ever-works/plugins';
import { seoPlugin } from '@ever-works/plugin-seo';
import { paginationPlugin } from '@ever-works/plugin-pagination';
import { filtersPlugin } from '@ever-works/plugin-filters';
import { searchPlugin } from '@ever-works/plugin-search';
import { sortPlugin } from '@ever-works/plugin-sort';
import { sitemapPlugin } from '@ever-works/plugin-sitemap';

export const plugins = definePlugins([
seoPlugin(),
paginationPlugin({ itemsPerPage: 12 }),
filtersPlugin(),
searchPlugin(),
sortPlugin({ defaultSort: 'name', defaultDirection: 'asc' }),
sitemapPlugin(),
]);

Disabling a plugin

Comment out or remove the line. The build works without any plugins:

export const plugins = definePlugins([
seoPlugin(),
paginationPlugin({ itemsPerPage: 12 }),
// filtersPlugin(), <-- disabled
// searchPlugin(), <-- disabled
sortPlugin({ defaultSort: 'name', defaultDirection: 'asc' }),
sitemapPlugin(),
]);

Changing plugin options

Pass different values to each plugin's factory function:

// Show 24 items per page instead of 12
paginationPlugin({ itemsPerPage: 24 }),

// Sort by date descending by default
sortPlugin({ defaultSort: 'updated_at', defaultDirection: 'desc' }),

Available built-in plugins

PluginPackageOptions
SEO@ever-works/plugin-seoMeta tags, Open Graph, JSON-LD
Pagination@ever-works/plugin-paginationitemsPerPage
Filters@ever-works/plugin-filtersClient-side category/tag filtering
Search@ever-works/plugin-searchStatic search via Pagefind
Sort@ever-works/plugin-sortdefaultSort, defaultDirection
Sitemap@ever-works/plugin-sitemapXML sitemap generation
RSS@ever-works/plugin-rssRSS 2.0 and Atom 1.0 feeds
Breadcrumbs@ever-works/plugin-breadcrumbsAuto-generated breadcrumb trails

See Creating a Plugin for building your own.


6. Custom CSS

Global styles

The file apps/web/src/styles/global.css is the entry point for all CSS. It imports Tailwind and defines theme variables. Add custom global rules at the bottom of this file:

/* apps/web/src/styles/global.css */
@import "tailwindcss";

/* ... @theme and :root blocks ... */

/* ── Custom global styles ─────────────────────────────── */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
h1, h2, h3, h4, h5, h6 {
@apply font-bold tracking-tight;
}
a {
@apply text-primary underline-offset-4 hover:underline;
}
}

Using @apply

Tailwind's @apply directive lets you extract repeated utility patterns into CSS classes. Use it in @layer components to avoid specificity issues:

@layer components {
.card-grid {
@apply grid gap-6 sm:grid-cols-2 lg:grid-cols-3;
}
.section-heading {
@apply text-2xl font-bold text-foreground mb-6;
}
.btn-primary {
@apply rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
hover:bg-primary/90 transition-colors;
}
}

Then use in templates:

<h2 class="section-heading">All Items</h2>
<div class="card-grid">
{items.map((item) => <ItemCard item={item} />)}
</div>

Component-scoped styles

For styles that apply only to a single Astro component, use a <style> block:

---
// MyComponent.astro
---
<div class="my-widget">
<p>Scoped styles example</p>
</div>

<style>
.my-widget {
container-type: inline-size;
}
@container (min-width: 400px) {
.my-widget p {
font-size: 1.25rem;
}
}
</style>

Astro scopes these styles automatically so they will not leak to other components.

Styling headless components via data attributes

Every UI component from @ever-works/ui renders data-component and data-part attributes. You can target these in your global CSS for site-wide overrides:

@layer components {
[data-component="item-card"] {
@apply rounded-xl border border-border bg-card p-6
transition-shadow hover:shadow-lg;
}
[data-component="item-card"] [data-part="title"] {
@apply text-lg font-semibold text-foreground;
}
[data-component="item-card"] [data-part="description"] {
@apply mt-1 text-sm text-muted-foreground line-clamp-2;
}
[data-component="item-card"][data-featured="true"] {
@apply border-primary/30 bg-primary/5;
}
}

This approach styles all instances of a component from a single place, without modifying individual page files.


7. Custom Data Fields

How the YAML schema works

Item data files (.content/data/<slug>/<slug>.yml) support arbitrary additional fields through a pass-through mechanism. The ItemData interface includes an index signature:

interface ItemData {
id: string;
name: string;
slug: string;
description: string;
source_url: string;
category: string | string[];
tags: string[];
featured?: boolean;
icon_url?: string;
// ... standard fields ...
[key: string]: unknown; // <-- any additional YAML fields pass through
}

Adding custom fields to items

Add any field to your YAML files. For example, a "pricing" and "github_stars" field:

# .content/data/my-tool/my-tool.yml
name: "My Tool"
slug: "my-tool"
description: "A great tool for developers."
source_url: "https://example.com"
category: "utilities"
tags: ["open-source", "typescript"]
status: "approved"
updated_at: "2026-01-15 00:00"

# Custom fields
pricing: "free"
github_stars: 12500
documentation_url: "https://example.com/docs"
license: "MIT"

Accessing custom fields in templates

Custom fields are available as properties on the item object. Since they use the unknown type, cast or check them before use:

---
import type { ItemData } from '@ever-works/core';

interface Props {
item: ItemData;
}

const { item } = Astro.props;

// Access custom fields with type narrowing
const pricing = typeof item.pricing === 'string' ? item.pricing : null;
const githubStars = typeof item.github_stars === 'number' ? item.github_stars : null;
const license = typeof item.license === 'string' ? item.license : null;
---

<div class="flex flex-wrap gap-3 mt-4">
{pricing && (
<span class="inline-flex items-center rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
{pricing === 'free' ? 'Free' : pricing}
</span>
)}
{githubStars && (
<span class="inline-flex items-center gap-1 text-sm text-muted-foreground">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{githubStars.toLocaleString()}
</span>
)}
{license && (
<span class="inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
{license}
</span>
)}
</div>

Custom fields on other data types

The same pass-through pattern works for pages (.content/pages/<slug>.md) via frontmatter:

---
title: "About Us"
description: "Learn about our team."
team_size: 5
founded_year: 2024
---

Content goes here...

Access it in pages/[slug].astro:

---
const page = await getPage(slug);
const teamSize = typeof page.team_size === 'number' ? page.team_size : null;
---

{teamSize && <p>Team of {teamSize} people</p>}

Using custom fields for filtering

If you add a custom field like pricing to all items, you can use it in a Preact island to build custom filter UI:

// apps/web/src/components/PricingFilter.tsx
import { useState } from 'preact/hooks';

interface Props {
items: Array<{ slug: string; name: string; pricing?: string }>;
}

export default function PricingFilter({ items }: Props) {
const [filter, setFilter] = useState<string>('all');

const filtered = filter === 'all'
? items
: items.filter((item) => item.pricing === filter);

const options = ['all', ...new Set(items.map((i) => i.pricing).filter(Boolean))];

return (
<div>
<div class="flex gap-2 mb-4">
{options.map((opt) => (
<button
key={opt}
onClick={() => setFilter(opt as string)}
class={`rounded-full px-3 py-1 text-sm ${
filter === opt
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent'
}`}
>
{opt === 'all' ? 'All' : String(opt)}
</button>
))}
</div>
<ul>
{filtered.map((item) => (
<li key={item.slug}>
<a href={`/item/${item.slug}`} class="text-primary hover:underline">
{item.name}
</a>
</li>
))}
</ul>
</div>
);
}

Quick Reference

What to customizeWhere to edit
Colors (light/dark)apps/web/src/styles/global.css -- :root and .dark blocks
Fontsapps/web/src/styles/global.css -- @theme inline block + font <link> in layout
Spacingapps/web/src/styles/global.css -- --spacing variable
Border radiusapps/web/src/styles/global.css -- --radius variable
Navigation linksapps/web/src/layouts/BaseLayout.astro -- navItems array
Header/footer stylePass class prop to SiteHeader / SiteFooter in BaseLayout.astro
Plugin configapps/web/src/lib/plugins.config.ts
Page routesapps/web/src/pages/ -- file-based routing
Global CSS rulesapps/web/src/styles/global.css -- @layer base or @layer components
Component-scoped CSS<style> block inside any .astro component
Custom data fields.content/data/<slug>/<slug>.yml -- add any YAML key

Next Steps