Surface Header

A site header that takes the colour of the section under it: clear over a photo, the section's own colour over a dark fill, the regular header everywhere else.

Default

Scroll inside the frame. The header reads the section under its middle.

Dark previewReplay previewOpen in new tab
Northwindlight

A photo runs under the header

A regular section. The header is back to its frosted look.

A dark section. The header takes its colour.

Usage

vue
<script setup>
import { SurfaceHeader } from "@/components/ui/surface";

const route = useRoute();
const menuOpen = ref(false);
</script>

<template>
  <SurfaceHeader
    v-slot="{ tone }"
    :initial="route.meta.headerTone"
    :disabled="menuOpen"
    class="sticky top-0 z-50 h-14 border-b"
  >
    <Logo :data-mono="tone === 'photo' ? '' : undefined" />
  </SurfaceHeader>
</template>

Declaring sections

A section says what it is painted with in data-surface: photo, dark or light. Undeclared sections read as light, so a site changes nothing until it declares one.

The nearest declared element under the header wins, so a light card inside a dark section can declare itself light. Page Scrollbar reads the same attribute.

vue
<!-- A full-bleed photo: the header goes clear, with light ink -->
<section data-surface="photo" class="relative -mt-14 h-svh">
  <img src="/hero.jpg" alt="" class="absolute inset-0 size-full object-cover" />
</section>

<!-- A dark fill: the header takes this section's colour -->
<section data-surface="dark" class="bg-zinc-950 text-white">...</section>

<!-- Undeclared, or "light": the regular header -->
<section>...</section>

First frame

The tone is measured in the browser. For a page whose top section is a photo, pass the first frame in initial so the server renders the clear header too.

vue
<!-- pages/index.vue: the hero is a photo, so the server renders the clear header -->
<script setup>
definePageMeta({ headerTone: "photo" });
</script>

When it reads

On scroll and resize, once a frame, and again whenever the page's nodes come or go or the page changes size, so a navigation, late content or an accordion opening under the header all count.

Hit testing goes blind twice: a modal's scroll lock makes the page pointer-events: none until its exit animation ends, and a view transition covers the page with its snapshot. Then the boxes of the declared sections are read instead. They are already the new page's, so the header has its tone before a closing menu finishes and from the first frame of a navigation.

Composable

useSurfaceTone gives any floating element the same reading. Scroll To Top uses it to pick its glass.

vue
<script setup>
import { useSurfaceTone } from "@/components/ui/surface";

// Any element that floats over the page: a floating button, a sticky toolbar.
const fab = ref(null);
const { tone, background } = useSurfaceTone(fab);
</script>

API Reference

SurfaceHeader

PropTypeDefaultDescription
asstring"header"Element to render.
initial"light" | "dark" | "photo"—Tone for the first frame and the server render.
disabledbooleanfalseHold the regular look, for instance while a menu sheet covers the page.
lightClassstringfrosted backgroundThe regular header, over light and undeclared sections.
darkClassstring"dark border-white/10 text-white backdrop-blur-sm"Over a dark section. The dark class scopes the dark theme tokens to the header.
photoClassstring"dark border-transparent bg-transparent text-white"Over a photo.
tintnumber92How much of a dark section's own colour the header takes, in percent.
SlotDescription
defaultReceives { tone, background }.

Accessibility

Keyboard shortcuts and ARIA behavior.

  • Only the colours change. Content, order and focus stay the same in every tone.
  • The colour change is instant when reduced motion is on.