---
name: header-centered
title: Header Centered
url: https://ui.frontic.com/components/templates/header-centered.md
tier: templates
install: npx @frontic/ui add header-centered
files:
  - components/templates/header-centered/HeaderCentered.vue
  - components/templates/header-centered/index.ts
registryDependencies:
  - badge
  - button
  - navigation-shop
dependencies:
  - "@lucide/vue"
---

# Header Centered

Store header with a centred logo between a search slot (inline on wide screens, an overlay button on phones) and account and cart actions, over a full-width navigation slot.

## Install

```sh
npx @frontic/ui add header-centered
```

Built from: [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Navigation Shop](https://ui.frontic.com/components/sections/navigation-shop.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { HeaderCentered, type NavigationCategory, type NavigationLink } from '@/components/templates/header-centered'
import { SearchSuggestions, type SearchProduct, type SearchResult } from '@/components/sections/search-suggestions'

const image = (name: string) => `https://static.frontic.com/cdn-cgi/image/width=640,format=auto/ui/products/${name}.jpg`

const categories: NavigationCategory[] = [
  {
    name: 'Women',
    featured: [
      { name: 'Mountain View Ski Goggles', href: '#', imageSrc: image('mountain-view-ski-goggles'), imageAlt: 'Mountain View Ski Goggles', wide: true },
      { name: 'Winterwonder Parka', href: '#', imageSrc: image('winterwonder-parka'), imageAlt: 'Winterwonder Parka' },
      { name: 'Northern Light Hybrid Jacket', href: '#', imageSrc: image('northern-light-hybrid-jacket-lavender'), imageAlt: 'Northern Light Hybrid Jacket' },
    ],
    columns: [
      {
        name: 'Winter',
        links: [
          { name: 'Jackets', href: '#' },
          { name: 'Beanies', href: '#' },
          { name: 'Goggles', href: '#' },
          { name: 'Helmets', href: '#' },
          { name: 'Gloves', href: '#' },
        ],
      },
      {
        name: 'Equipment',
        links: [{ name: 'Backpacks', href: '#' }],
      },
    ],
  },
  {
    name: 'Men',
    featured: [
      { name: 'Snow Falcon Glide', href: '#', imageSrc: image('snow-falcon-glide'), imageAlt: 'Snow Falcon Glide', wide: true },
      { name: 'Frost Escape Parka', href: '#', imageSrc: image('frost-escape-parka'), imageAlt: 'Frost Escape Parka' },
      { name: 'TrailMaster 22', href: '#', imageSrc: image('trailmaster-22'), imageAlt: 'TrailMaster 22' },
    ],
    columns: [
      {
        name: 'Winter',
        links: [
          { name: 'Jackets', href: '#' },
          { name: 'Beanies', href: '#' },
          { name: 'Goggles', href: '#' },
          { name: 'Helmets', href: '#' },
        ],
      },
      {
        name: 'Equipment',
        links: [{ name: 'Backpacks', href: '#' }],
      },
    ],
  },
  { name: 'Equipment', href: '#' },
]

const pages: NavigationLink[] = [{ name: 'Sale', href: '#', highlight: true }]

// The Women flyout shows open where the bar has room for it; on a phone the bar shows closed.
const menu = ref('Women')
const product = (name: string, slug: string, meta: string, amount: number): SearchProduct => ({ name, href: `#/p/${slug}`, image: image(slug), meta, price: { amount, currency: 'EUR' } })
const catalog = [
  product('Frost Escape Parka', 'frost-escape-parka', 'Sun yellow · Jackets', 14900),
  product('Winterwonder Parka', 'winterwonder-parka', 'Jackets', 18900),
  product('Snowflake Ski Goggles', 'snowflake-ski-goggles', 'Mint · Goggles', 12700),
  product('TrailMaster 22', 'trailmaster-22', 'Brown · 20L · Backpacks', 14100),
]
const suggestions = [{ label: 'parka' }, { label: 'ski goggles' }, { label: 'backpack' }]
const query = ref('')
const results = computed<SearchResult>(() => {
  const term = query.value.trim().toLowerCase()
  const has = (text: string) => text.toLowerCase().includes(term)
  return { query: query.value.trim(), products: catalog.filter((item) => has(item.name) || has(item.meta ?? '')), categories: [], brands: [] }
})
</script>

<template>
  <div class="@container">
    <div class="@5xl:min-h-180">
      <HeaderCentered v-model:menu="menu" logo-text="Basecamp" :categories="categories" :pages="pages" :cart-count="2">
        <template #search="{ presentation }">
          <SearchSuggestions v-model:query="query" :presentation="presentation" :results="results" :suggestions="suggestions" placeholder="Search jackets, goggles, packs" />
        </template>
      </HeaderCentered>
    </div>
  </div>
</template>
```

The example also uses `search-suggestions`, which `add header-centered` leaves out: `npx @frontic/ui add search-suggestions`.

## API reference

### HeaderCentered

Shows the logo and navigation with search, account and cart buttons. Renders a `<header>`.

`data-slot="header-centered"` · `HeaderCentered.vue`

Styling: `header-centered-logo`, which extends [`logo`](https://ui.frontic.com/DESIGN.md#logo). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `categories` | `NavigationCategory[]` | `[]` | The top-level categories; one with featured tiles or link columns opens a flyout, one without is a plain link. |
| `pages` | `NavigationLink[]` | `[]` | Plain links after the categories, such as Sale or Journal. |
| `menuLabel` | `string` | `'Open menu'` | Screen-reader name of the button that opens the mobile menu. |
| `sheetTitle` | `string` |  | Screen-reader title of the mobile menu. |
| `featuredCta` | `string` |  | Shown under each featured tile's name, e.g. `Shop now`. |
| `logoText` | `string` | `'STORE'` | Shown as the logo, or as the logo image's alt text when `logoSrc` is set. |
| `logoSrc` | `string` | `''` | URL of the logo image; `logoText` shows in its place when empty. |
| `homeHref` | `string` | `'/'` | Where the logo links to. |
| `cartCount` | `number` | `0` | Number on the cart button's badge; the badge hides at 0. |
| `searchLabel` | `string` | `'Search'` | Screen-reader name of the search button. |
| `accountLabel` | `string` | `'Account'` | Screen-reader name of the account button. |
| `cartLabel` | `string` | `'Cart'` | Screen-reader name of the cart button. |
| `cartOverflowLabel` | `string` | `'9+'` | Shown on the cart badge in place of counts above 9. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `menu` | `string` | `''` | The navigation's open flyout, by category name. |
| `menuOpen` | `boolean` | `false` | Whether the navigation's mobile sheet is open. |

| Event | Payload | Description |
| --- | --- | --- |
| `search` |  | Fires when the shopper clicks the search button; not used when the search slot fills it. |
| `update:menu` | `value: string` | Fires with the new value when `menu` changes; `v-model:menu` binds it. |
| `update:menuOpen` | `value: boolean` | Fires with the new value when `menuOpen` changes; `v-model:menuOpen` binds it. |
| `account` |  | Fires when the shopper clicks the account button. |
| `cart` |  | Fires when the shopper clicks the cart button. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `navigation` | `{ categories: NavigationCategory[]; pages: NavigationLink[]; }` | Replaces the navigation; gets the categories and pages. |
| `search` | `{ presentation: "inline" \| "overlay"; }` | Holds the search, rendered twice: inline beside the menu from |

## Exports

```ts
import { type NavigationCategory, type NavigationLink } from '@/components/templates/header-centered'
```

- `NavigationCategory`, `NavigationLink`: from [Navigation Shop](https://ui.frontic.com/components/sections/navigation-shop.md)

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`header-centered`](https://ui.frontic.com/DESIGN.md#header-centered) |
| `logo` | [`header-centered-logo`](https://ui.frontic.com/DESIGN.md#header-centered-logo) |
| `nav` | [`header-centered-nav`](https://ui.frontic.com/DESIGN.md#header-centered-nav) |
| `focus-ring` | [`focus-ring`](https://ui.frontic.com/DESIGN.md#focus-ring) |

States with a look of their own: focus.

## Source

`components/templates/header-centered/HeaderCentered.vue`

```vue
<script lang="ts">
/** Shows the logo and navigation with search, account and cart buttons. */
export default {}
</script>

<script setup lang="ts">
import { MenuIcon, SearchIcon, UserIcon, ShoppingBagIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import { useSlots } from 'vue'
import { Badge } from '@library/components/ui/badge'
import { Button } from '@library/components/ui/button'
import { NavigationShop, type NavigationCategory, type NavigationLink } from '@library/components/sections/navigation-shop'

const props = withDefaults(
  defineProps<{
    /** The top-level categories; one with featured tiles or link columns opens a flyout, one without is a plain link. */
    categories?: NavigationCategory[]
    /** Plain links after the categories, such as Sale or Journal. */
    pages?: NavigationLink[]
    /** Screen-reader name of the button that opens the mobile menu. */
    menuLabel?: string
    /** Screen-reader title of the mobile menu. */
    sheetTitle?: string
    /** Shown under each featured tile's name, e.g. `Shop now`. */
    featuredCta?: string
    /** Shown as the logo, or as the logo image's alt text when `logoSrc` is set. */
    logoText?: string
    /** URL of the logo image; `logoText` shows in its place when empty. */
    logoSrc?: string
    /** Where the logo links to. */
    homeHref?: string
    /** Number on the cart button's badge; the badge hides at 0. */
    cartCount?: number
    /** Screen-reader name of the search button. */
    searchLabel?: string
    /** Screen-reader name of the account button. */
    accountLabel?: string
    /** Screen-reader name of the cart button. */
    cartLabel?: string
    /** Shown on the cart badge in place of counts above 9. */
    cartOverflowLabel?: string
    class?: HTMLAttributes['class']
  }>(),
  {
    categories: () => [],
    pages: () => [],
    menuLabel: 'Open menu',
    logoText: 'STORE',
    logoSrc: '',
    homeHref: '/',
    cartCount: 0,
    searchLabel: 'Search',
    accountLabel: 'Account',
    cartLabel: 'Cart',
    cartOverflowLabel: '9+',
  },
)
/** The navigation's open flyout, by category name. */
const menu = defineModel<string>('menu', { default: '' })
/** Whether the navigation's mobile sheet is open. */
const menuOpen = defineModel<boolean>('menuOpen', { default: false })
const emit = defineEmits<{
  /** Fires when the shopper clicks the search button; not used when the search slot fills it. */
  search: []
  /** Fires when the shopper clicks the account button. */
  account: []
  /** Fires when the shopper clicks the cart button. */
  cart: []
}>()
defineSlots<{
  /** Replaces the navigation; gets the categories and pages. */
  navigation?: (props: { categories: NavigationCategory[]; pages: NavigationLink[] }) => unknown
  /** Holds the search, rendered twice: inline beside the menu from @5xl, and as the button that opens an overlay below that. */
  search?: (props: { presentation: 'inline' | 'overlay' }) => unknown
}>()
// Below @5xl its own navigation is only a menu button: it moves into the bar and the navigation row folds away. A passed navigation keeps its row.
const ownNavigation = !useSlots().navigation
</script>

<template>
  <header data-slot="header-centered" :class="['fui-header-centered @container sticky top-0 z-50', props.class]">
    <div class="mx-auto flex h-16 w-full max-w-384 items-center justify-between px-4 @2xl:px-6 @5xl:px-8">
      <div class="flex flex-1 items-center gap-1">
        <div v-if="ownNavigation" class="flex @5xl:hidden">
          <Button variant="ghost" color="neutral" size="icon-md" :aria-label="menuLabel" @click="menuOpen = true">
            <MenuIcon data-icon />
          </Button>
        </div>
        <template v-if="$slots.search">
          <div class="flex @5xl:hidden">
            <slot name="search" presentation="overlay" />
          </div>
          <div class="hidden w-64 @5xl:block">
            <slot name="search" presentation="inline" />
          </div>
        </template>
        <Button v-else variant="ghost" color="neutral" size="icon-md" :aria-label="searchLabel" @click="emit('search')">
          <SearchIcon data-icon />
        </Button>
      </div>
      <a :href="homeHref" data-slot="header-centered-logo" class="fui-header-centered-logo outline-none focus-visible:fui-focus-ring">
        <img v-if="logoSrc" :src="logoSrc" :alt="logoText" class="h-8 w-auto" />
        <template v-else>{{ logoText }}</template>
      </a>
      <div class="flex flex-1 items-center justify-end gap-1">
        <div class="hidden @2xl:flex">
          <Button variant="ghost" color="neutral" size="icon-md" :aria-label="accountLabel" @click="emit('account')">
            <UserIcon data-icon />
          </Button>
        </div>
        <Button variant="ghost" color="neutral" size="icon-md" :aria-label="cartLabel" class="relative" @click="emit('cart')">
          <ShoppingBagIcon data-icon />
          <Badge v-if="cartCount > 0" variant="primary" size="sm" class="absolute -top-1 -inset-e-1">{{ cartCount > 9 ? cartOverflowLabel : cartCount }}</Badge>
        </Button>
      </div>
    </div>
    <div :class="{ 'hidden @5xl:block': ownNavigation }">
      <div data-slot="header-centered-nav" class="fui-header-centered-nav">
        <div class="mx-auto w-full max-w-384 px-4 py-2 @2xl:px-6 @5xl:px-8">
          <div class="flex justify-center">
            <slot name="navigation" :categories="categories" :pages="pages">
              <NavigationShop
                layout="captioned"
                v-model:menu="menu"
                v-model:open="menuOpen"
                :categories="categories"
                :pages="pages"
                :menu-label="menuLabel"
                :sheet-title="sheetTitle"
                :featured-cta="featuredCta"
              />
            </slot>
          </div>
        </div>
      </div>
    </div>
  </header>
</template>
```

## API of the items the example uses

### Search Suggestions

[Full docs](https://ui.frontic.com/components/sections/search-suggestions.md) · `npx @frontic/ui add search-suggestions`

#### SearchSuggestions

A storefront search: products, categories and brands while the shopper types, suggestions and curated picks before. Renders a `<div>`.

`data-slot="search-suggestions"` · `SearchSuggestions.vue`

Styling: `search-suggestions-image`, which extends [`thumbnail`](https://ui.frontic.com/DESIGN.md#thumbnail); [`line-title`](https://ui.frontic.com/DESIGN.md#line-title); [`meta`](https://ui.frontic.com/DESIGN.md#meta). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `results` | `SearchResult \| null`: `null \| SearchResult` |  | The shop's answer to the query; shown only while it answers the query in the field. |
| `loading` | `boolean` | `false` | Shows the loading rows while the shop's search runs. |
| `suggestions` | `SearchSuggestion[]` | `[]` | Searches offered as tags before typing and when nothing is found. |
| `collections` | `SearchCollection[]` | `[]` | Curated rows of products, such as staff picks, shown before typing and when nothing is found. |
| `highlight` | `string \| string[]` |  | What counts as a match in the names: the typed query when left out, or the terms the shop's search matched. |
| `presentation` | `'inline' \| 'overlay'` | `'inline'` | Inline floats the panel under the field; overlay shows a search button that opens it full screen on phones and near the top on wide screens. |
| `searchHref` | `((query: string) => string)`: `(query: string): string` |  | The results page for a query; links the suggestion tags and the "See all" row. Without it, tags run in the panel. |
| `shortcut` | `boolean` | `false` | Lets "/" focus the field, or open the overlay, while focus is not in another field. |
| `placeholder` | `string` | `'Search'` | Shown in the empty field. |
| `searchLabel` | `string` | `'Search'` | Screen-reader name of the field and of the overlay's search button. |
| `productsLabel` | `string` | `'Products'` | The heading over the products. |
| `categoriesLabel` | `string` | `'Categories'` | The heading over the categories. |
| `brandsLabel` | `string` | `'Brands'` | The heading over the brands. |
| `popularLabel` | `string` | `'Popular searches'` | The heading over the suggested searches before typing. |
| `noResultsLabel` | `((query: string) => string)`: `(query: string): string` | ``No results for '${query}'. Try one of these:`` | Builds the line shown when a search finds nothing. |
| `seeAllLabel` | `((total: number) => string)`: `(total: number): string` | ``See all ${total} results`` | Builds the "See all" row from the total; it shows when the total is more than the products listed. |
| `resultsAnnouncement` | `((count: number) => string)`: `(count: number): string` | `count === 1 ? '1 result' : `${count} results`` | Builds what screen readers hear when results land. |
| `cancelLabel` | `string` | `'Cancel'` | The overlay's Cancel button on phones. |
| `locale` | `string` |  | The products' price locale, e.g. `de-DE`; the ConfigProvider's when left out. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `query` | `string` | `''` | What the shopper typed; run the shop's search on every change. |
| `open` | `boolean` | `false` | Whether the panel is open, or the overlay in that presentation. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:open` | `value: boolean` | Fires with the new value when `open` changes; `v-model:open` binds it. |
| `select` | `href: string` | The shopper opened a row; gets its `href`, which the link also follows. |
| `submit` | `query: string` | The shopper asked for every result for the query. |
| `update:query` | `value: string` | Fires with the new value when `query` changes; `v-model:query` binds it. |
