Skip to main content
On this page

Per-page header inside the main content area. Not a global header — each page optionally includes this component for its title and actions.


Why No Global Header?

Global Header PageHeader (per-page)
Wastes vertical space Only where needed
One-size-fits-all Page-specific actions
Competes with sidebar Clean separation

Structure

┌──────┬─────────────────────────────────────────┐
│      │  ┌───────────────────────────────────┐  │
│      │  │ Breadcrumbs (optional)            │  │
│      │  │ Page Title          [Actions]     │  │ ← PageHeader
│      │  ├───────────────────────────────────┤  │
│ Side │  │                                   │  │
│ bar  │  │  Page content...                  │  │
│      │  │                                   │  │
│      │  └───────────────────────────────────┘  │
│      │  Footer                                 │
└──────┴─────────────────────────────────────────┘

PageHeader Anatomy

┌─────────────────────────────────────────────────┐
│  Projects › Project Alpha                       │  ← Breadcrumbs (optional)
│  Project Alpha                    [Edit] [⋮]   │  ← Title + Actions
└─────────────────────────────────────────────────┘
Element Description
Breadcrumbs Optional navigation trail
Title Page or resource name
Actions Primary actions (buttons, dropdown)

Component

<!-- src/lib/components/composites/page-header/PageHeader.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { cn } from '$lib/utils/cn';

  interface Breadcrumb {
    label: string;
    href?: string;
  }

  interface Props {
    title: string;
    breadcrumbs?: Breadcrumb[];
    actions?: Snippet;
    class?: string;
  }

  let { title, breadcrumbs, actions, class: className }: Props = $props();
</script>

<header class={cn('mb-6', className)}>
  {#if breadcrumbs?.length}
    <nav class="mb-2 text-sm text-muted" aria-label="Breadcrumb">
      <ol class="flex items-center gap-1">
        {#each breadcrumbs as crumb, i}
          {#if i > 0}
            <li class="text-muted/50">/</li>
          {/if}
          <li>
            {#if crumb.href}
              <a href={crumb.href} class="hover:text-fg">{crumb.label}</a>
            {:else}
              <span>{crumb.label}</span>
            {/if}
          </li>
        {/each}
      </ol>
    </nav>
  {/if}

  <div class="flex items-center justify-between gap-4">
    <h1 class="text-2xl font-semibold text-fg">{title}</h1>

    {#if actions}
      <div class="flex items-center gap-2">
        {@render actions()}
      </div>
    {/if}
  </div>
</header>

Usage

<!-- src/routes/app/projects/[id]/+page.svelte -->
<script>
  import { PageHeader } from '$lib/components/composites';
  import { Button, DropdownMenu } from '$lib/components/primitives';

  let { data } = $props();
</script>

<PageHeader
  title={data.project.name}
  breadcrumbs={[
    { label: 'Projects', href: '/app/projects' },
    { label: data.project.name }
  ]}
>
  {#snippet actions()}
    <Button intent="secondary">Edit</Button>
    <DropdownMenu>
      <DropdownItem>Duplicate</DropdownItem>
      <DropdownItem>Archive</DropdownItem>
      <DropdownItem destructive>Delete</DropdownItem>
    </DropdownMenu>
  {/snippet}
</PageHeader>

<!-- Page content below -->

Sticky Option

For long pages, PageHeader can stick to top on scroll:

<PageHeader title="Dashboard" class="sticky top-0 bg-bg z-10" />

Security: Dynamic Content

When title or breadcrumbs come from user-controlled data (e.g., project names), be aware of XSS risks.

Svelte Auto-Escapes (Safe by Default)

<!-- ✅ SAFE: Svelte escapes {title} automatically -->
<h1>{title}</h1>

<!-- ✅ SAFE: Svelte escapes {crumb.label} automatically -->
<span>{crumb.label}</span>

Never Use {@html} for User Content

<!-- ❌ DANGEROUS: Never do this with user data -->
<h1>{@html projectName}</h1>

<!-- If you MUST render HTML (e.g., markdown), sanitize first -->
<script>
  import DOMPurify from 'dompurify';
  let safeTitle = DOMPurify.sanitize(projectName);
</script>
<h1>{@html safeTitle}</h1>

Truncation for Long Titles

User-controlled titles can be very long. Truncate to prevent layout breaking:

<h1 class="text-2xl font-semibold truncate max-w-md" title={title}>
  {title}
</h1>

Breadcrumb Validation

If breadcrumbs contain user-controlled href values, validate the URL:

function isValidHref(href: string): boolean {
  // Only allow relative paths or same-origin URLs
  if (href.startsWith('/')) return true;
  try {
    const url = new URL(href, window.location.origin);
    return url.origin === window.location.origin;
  } catch {
    return false;
  }
}
{#if crumb.href && isValidHref(crumb.href)}
  <a href={crumb.href}>{crumb.label}</a>
{:else}
  <span>{crumb.label}</span>
{/if}
← Back to Blueprint