Search pages, headings and code across the docs, examples and tutorials.

to navigateto open

Lesson 03

Header and footer

Move Rayleigh's logo, menu and footer links into a settings record the CMS edits, read them in Nuxt, and add a dark mode that never flashes.

Start from the previous lesson's code

The Rayleigh home page with the logo, the Portfolio menu item, the theme switch and the galleries

Settings

Content the whole site shares lives in a singleton, a collection with exactly one record. See Site data and Singletons.

apps/backend/collections/Settings.ts
import { defineCollection, field } from 'ohnejs';

// The site-wide settings your header and footer read: the logo, the menu and the footer links.
// A singleton holds exactly one record, so the dashboard opens it straight away.
// See https://ohne.dev/docs/cms/website#site-data
export default defineCollection({
  singleton: true,
  // Anyone may read the settings, and only an editor may change them.
  api: { read: 'public', update: true },
  fields: {
    logo: field('image', {
      description: 'Drawn in one dark color. The dark theme shows it inverted.',
    }),
    menu: field('repeater', {
      fields: {
        label: field('text'),
        // A link to a page follows it when its slug changes.
        link: field('link', { collections: ['Pages'] }),
      },
      layout: [{ row: ['label', 'link'] }],
    }),
    copyright: field('text', { nullable: true }),
    social: field('repeater', {
      fields: {
        icon: field('select', {
          choices: [
            { value: 'github', label: 'GitHub' },
            { value: 'x', label: 'X' },
            { value: 'discord', label: 'Discord' },
            { value: 'website', label: 'Website' },
          ],
        }),
        // Without `collections`, a link holds an address only.
        link: field('link'),
        title: field('text', {
          nullable: true,
          description: 'Shown when a visitor hovers the icon.',
        }),
      },
      layout: [{ row: ['icon | 10rem', 'link', 'title'] }],
    }),
  },
  dashboard: {
    icon: 'settings',
    layout: [
      {
        tabs: [
          { label: 'Header', fields: ['logo', 'menu'] },
          { label: 'Footer', fields: ['copyright', 'social'] },
        ],
      },
    ],
  },
});

The content

Copy the logo and its credits from this lesson's tag:

Terminal
curl -sL https://github.com/ohnejs/rayleigh-nuxt/archive/refs/tags/lesson-03-header-and-footer.tar.gz \
  | tar -xzf - --strip-components=1 \
    rayleigh-nuxt-lesson-03-header-and-footer/apps/backend/seed/images/logo.svg \
    rayleigh-nuxt-lesson-03-header-and-footer/apps/backend/seed/CREDITS.md

The seed uploads it, and keeps the home page so the menu can link to it:

apps/backend/boot/seed.tslines 26-37
  const logo = await upload('logo.svg', 'Rayleigh');

  const portfolio = [];
  for (const { title, directory, items } of galleries) {
    const gallery = [];
    for (const [name, caption] of items) {
      gallery.push({ image: await upload(`${directory}/${name}.webp`, caption), caption });
    }
    portfolio.push({ block: 'Portfolio' as const, fields: { title, gallery } });
  }

  const home = await query('Pages').createOrThrow({

Then it fills Settings:

apps/backend/boot/seed.tslines 59-76
  // A singleton always has its one record, so the seed updates it instead of creating it.
  await query('Settings').update({
    logo,
    menu: [{ label: 'Portfolio', link: { collection: 'Pages', record: home.UUID } }],
    copyright: '© 2026 Rayleigh. Built with ohne.',
    social: [
      {
        icon: 'github',
        link: { url: 'https://github.com/ohnejs', newTab: true },
        title: 'ohne on GitHub',
      },
      {
        icon: 'website',
        link: { url: 'https://ohne.dev/examples/rayleigh', newTab: true },
        title: 'Rayleigh on ohne.dev',
      },
    ],
  });

Stop pnpm dev, delete the data and start again, then create your admin account once more:

Terminal
rm -rf apps/backend/.data apps/backend/.uploads
pnpm dev

Settings in the dashboard now holds the header and the footer, each on its own tab:

The Settings record in the dashboard, with the logo upload and the Portfolio menu item on its Header tab

Reading the settings

The header, the menu and the footer read Settings with useOhneRead, and the logo becomes the upload. See Frameworks.

apps/web/app/components/Logo.vue
<script setup lang="ts">
import type { Upload } from '~~/ohne';

// The logo is an upload in `Settings`, so an editor can replace it in the dashboard.
defineProps<{ logo: Upload | null }>();
</script>

<template>
  <img
    v-if="logo"
    class="logo"
    :src="logo.url"
    :width="logo.width ?? undefined"
    :height="logo.height ?? undefined"
    :alt="logo.description ?? 'Rayleigh'"
    decoding="async"
  />
</template>
apps/web/app/components/Menu.vue
<script setup lang="ts">
import { linkHref } from '@ohnejs/client';

const settings = await useOhneRead('Settings');
const open = useMobileMenu();
const route = useRoute();
const list = useTemplateRef('list');

// The dot's distance from the menu's left edge, or `null` to hide it.
const dot = ref<number | null>(null);

// `linkHref` turns a link field into its address.
// An item is active on its own page and on every page below it, so Blog stays marked on a post.
const active = computed(() =>
  settings.value.menu.findIndex(({ link }) => {
    const href = linkHref(link);
    return href === route.path || (!!href && href !== '/' && route.path.startsWith(`${href}/`));
  }),
);

// The dot sits under the active item, and follows the pointer while it is over the menu.
function moveDot(index = active.value) {
  const item = active.value < 0 ? undefined : list.value?.children[index];
  dot.value = item instanceof HTMLElement ? item.offsetLeft : null;
}

const onResize = () => moveDot();

watch(active, onResize);

onMounted(() => {
  moveDot();
  addEventListener('resize', onResize);
});

onBeforeUnmount(() => removeEventListener('resize', onResize));
</script>

<template>
  <nav v-if="settings.menu.length" id="menu" class="menu" :class="{ 'menu--open': open }">
    <ul ref="list" class="menu__list" @mouseleave="moveDot()">
      <li v-for="(item, index) in settings.menu" :key="item.UUID" @mouseenter="moveDot(index)">
        <!-- `OhneLink` renders a link field as a `NuxtLink`, so moving between pages needs no reload. -->
        <OhneLink
          :link="item.link"
          class="menu__link"
          :class="{ 'menu__link--active': index === active }"
          @click="open = false"
        >
          {{ item.label }}
        </OhneLink>
      </li>
    </ul>
    <span v-if="dot !== null" class="menu__dot" :style="{ '--dot-x': `${dot}px` }" />
  </nav>
</template>
apps/web/app/components/Header.vue
<script setup lang="ts">
// The header, the menu and the footer each read `Settings`.
// `useOhneRead` keys the read by its collection, so they share one request.
const settings = await useOhneRead('Settings');
const open = useMobileMenu();
const route = useRoute();

// The open menu covers the page, so the page behind it stops scrolling.
watch(open, (value) => {
  document.documentElement.classList.toggle('scroll-lock', value);
  if (value) window.scrollTo({ top: 0, behavior: 'smooth' });
});

watch(
  () => route.path,
  () => (open.value = false),
);

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') open.value = false;
}

onMounted(() => addEventListener('keydown', onKeydown));

onBeforeUnmount(() => {
  removeEventListener('keydown', onKeydown);
  document.documentElement.classList.remove('scroll-lock');
});
</script>

<template>
  <header class="container header">
    <NuxtLink to="/" class="header__home" aria-label="Rayleigh">
      <Logo :logo="settings.logo" />
    </NuxtLink>
    <Menu />
    <DarkModeSwitch />
    <button
      type="button"
      class="header__burger"
      aria-controls="menu"
      :aria-expanded="open"
      :title="open ? 'Close menu' : 'Open menu'"
      @click="open = !open"
    >
      <span />
      <span />
    </button>
  </header>
</template>
apps/web/app/components/Footer.vue
<script setup lang="ts">
const settings = await useOhneRead('Settings');

// The names of the icons an editor picks from in `Settings`, for a link without a title.
const names = { github: 'GitHub', x: 'X', discord: 'Discord', website: 'Website' };
</script>

<template>
  <footer class="container footer">
    <p v-if="settings.copyright" class="footer__copyright">{{ settings.copyright }}</p>
    <ul v-if="settings.social.length" class="footer__social">
      <li v-for="item in settings.social" :key="item.UUID">
        <OhneLink
          :link="item.link"
          class="footer__social-link"
          :title="item.title ?? undefined"
          :aria-label="item.title ?? names[item.icon]"
        >
          <IconBrandGithub v-if="item.icon === 'github'" />
          <IconBrandX v-else-if="item.icon === 'x'" />
          <IconBrandDiscord v-else-if="item.icon === 'discord'" />
          <IconWorld v-else />
        </OhneLink>
      </li>
    </ul>
  </footer>
</template>

The footer's website link gets a globe icon:

apps/web/app/components/icon/World.vue
<template>
  <svg fill="none" height="12" viewBox="0 0 12 12" width="12" aria-hidden="true">
    <path
      d="M10.5 6C10.5 8.48528 8.48528 10.5 6 10.5C3.51472 10.5 1.5 8.48528 1.5 6C1.5 3.51472 3.51472 1.5 6 1.5C8.48528 1.5 10.5 3.51472 10.5 6Z"
      stroke="currentColor"
      stroke-width="0.75"
    />
    <path
      d="M1.75 4.5H10.25M1.75 7.5H10.25M6 1.5C4.75 2.75 4.125 4.25 4.125 6C4.125 7.75 4.75 9.25 6 10.5C7.25 9.25 7.875 7.75 7.875 6C7.875 4.25 7.25 2.75 6 1.5Z"
      stroke="currentColor"
      stroke-linecap="round"
      stroke-linejoin="round"
      stroke-width="0.75"
    />
  </svg>
</template>
apps/web/app/composables/mobile-menu.ts
/**
 * Whether the mobile menu is open.
 * `useState` shares one value between the header's burger and the menu, and keeps it across pages.
 */
export const useMobileMenu = () => useState('mobile-menu', () => false);
apps/web/app/utils/theme.ts
/**
 * The two themes a visitor can pick.
 */
export type Theme = 'light' | 'dark';

const KEY = 'theme';

// Runs in the page's `<head>`, before the first paint, so the page never flashes the wrong theme.
// It follows the system setting until the visitor picks a theme, then keeps their pick.
export const themeScript = `(() => {
  const media = matchMedia('(prefers-color-scheme: dark)');
  const apply = () => {
    let theme = null;
    try { theme = localStorage.getItem('${KEY}'); } catch {}
    document.documentElement.dataset.theme =
      theme === 'light' || theme === 'dark' ? theme : media.matches ? 'dark' : 'light';
  };
  apply();
  media.addEventListener('change', apply);
})();`;

/**
 * The theme the page shows right now.
 */
export function currentTheme(): Theme {
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
}

/**
 * Shows `theme` and remembers it for the next visit.
 * A browser that blocks storage still switches, and forgets the pick on reload.
 */
export function setTheme(theme: Theme): void {
  document.documentElement.dataset.theme = theme;
  try {
    localStorage.setItem(KEY, theme);
  } catch {}
}
apps/web/app/components/DarkModeSwitch.vue
<script setup lang="ts">
// The server cannot know a visitor's theme, so it renders the same button for both.
// CSS picks the icon from the `data-theme` the theme script set, so the page never flashes.
// Each click turns the sun and the moon half a circle further, starting from the current theme.
const turns = ref<number>();

function toggle() {
  const dark = currentTheme() === 'dark';
  turns.value = (turns.value ?? (dark ? 1 : 0)) + 1;
  setTheme(dark ? 'light' : 'dark');
}
</script>

<template>
  <button
    type="button"
    class="theme-switch"
    aria-label="Toggle dark mode"
    title="Toggle dark mode"
    :style="turns === undefined ? undefined : { '--turns': turns }"
    @click="toggle"
  >
    <span class="theme-switch__orbit">
      <IconSun class="theme-switch__sun" />
      <IconMoon class="theme-switch__moon" />
    </span>
  </button>
</template>

Run the theme script in the page's <head>, before anything shows:

apps/web/nuxt.config.tslines 1-3
import { themeScript } from './app/utils/theme';

export default defineNuxtConfig({
apps/web/nuxt.config.tslines 33-36
      ],
      // Picks the light or dark theme while the page loads, before anything shows.
      script: [{ innerHTML: themeScript }],
    },

Styles

The dark theme, the burger and the switch are plain CSS. Copy the stylesheets:

Terminal
curl -sL https://github.com/ohnejs/rayleigh-nuxt/archive/refs/tags/lesson-03-header-and-footer.tar.gz \
  | tar -xzf - --strip-components=1 rayleigh-nuxt-lesson-03-header-and-footer/apps/web/app/assets/styles
apps/web/app/assets/styles/tokens.css
/* Every color, font and size the other stylesheets use. The dark theme swaps the colors only. */
/* Breakpoints, widest first: 1199px (small laptop), 1023px (tablet), 767px (phone). */
:root {
  --scheme: light;
  --heading: #010203;
  --copy: #234345;
  --accent: #268baa;
  --border: #e2e4e8;
  --dim: #59797b;
  --page: #fff;
  --ink: #010203;
  --overlay: rgb(1 2 3 / 0.7);
  --backdrop: rgb(1 2 3 / 0.8);
  --menu-backdrop: rgb(255 255 255 / 0.75);
  --badge: #234345;
  --badge-icon: #fff;

  --font-copy: Lato, ui-sans-serif, system-ui, sans-serif;
  --font-heading: Poppins, ui-sans-serif, system-ui, sans-serif;

  --container: 73.75rem;
  --content: 48.75rem;
  --gutter: 3rem;
  --section: 5.75rem;
  --radius: 0.375rem;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --fast: 150ms;
  --slow: 300ms;
}

/* A visitor who asks for less motion gets every change at once, a theme switch included. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --fast: 0ms;
    --slow: 0ms;
  }
}

/* The theme script in the page's `<head>` sets `data-theme` before the first paint. */
:root[data-theme='dark'] {
  --scheme: dark;
  --heading: #fff;
  --copy: rgb(255 255 255 / 0.8);
  --border: rgb(255 255 255 / 0.1);
  --page: #010203;
  --menu-backdrop: rgb(1 2 3 / 0.8);
  --badge: #fff;
  --badge-icon: #010203;
}

@media (max-width: 1199px) {
  :root {
    --gutter: 2rem;
  }
}

@media (max-width: 767px) {
  :root {
    --gutter: 1.5rem;
  }
}

Narrow the window below 1024 pixels and the menu folds into the burger:

Rayleigh on a phone with the menu open over a blurred page

Rename the menu item in Settings and the header follows. The next lesson, About page, adds a second page to the menu.

Checkpoint

The code at this lesson

Every lesson ends at a tag. Clone it to start from here, or compare it with yours.

Terminal
git clone --branch lesson-03-header-and-footer https://github.com/ohnejs/rayleigh-nuxt rayleigh
Browse on GitHub