Skip to content
edmi-ui

App Header

App top bar for the navbar layout: brand, nav pills, search, network button and wallet connect.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
⌘K
Terminal window
npx shadcn@latest add @edmi-ui/app-header
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/app-header
Terminal window
yarn dlx shadcn@latest add @edmi-ui/app-header
Terminal window
bunx shadcn@latest add @edmi-ui/app-header

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/app-header
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/app-header
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/app-header
Terminal window
bunx shadcn-vue@latest add @edmi-ui/app-header

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/app-header.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/app-header.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/app-header.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/app-header.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { AppHeader } from "@/components/app-header/app-header";
<AppHeader
name="Stockbreak"
network="Devnet"
active="#leaderboard"
/>
<script setup lang="ts">
import { AppHeader } from "@/components/ui/app-header";
</script>
<template>
<AppHeader name="Stockbreak" network="Devnet"
active="#leaderboard"
/>
</template>
<script lang="ts">
import { AppHeader } from "$lib/components/ui/app-header";
</script>
<AppHeader name="Stockbreak" network="Devnet"
active="#leaderboard"
/>
registry/blocks/app-header/app-header.tsx
"use client";
import { cn } from "cn";
import type * as React from "react";
import { ChartLineUpIcon, MagnifyingGlassIcon, WalletIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import {
controlLevel,
type Elevation,
type ElevationLevel,
surfaceElevation,
useElevation,
} from "@/registry/edmi/ui/elevation";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/edmi/ui/input-group";
import { Kbd } from "@/registry/edmi/ui/kbd";
type AppHeaderItem = { label: string; href: string };
// A header that rises hands the same depth to the controls it renders; `flat` and `sunken` keep them flat.
const rises = (level: ElevationLevel) =>
level === "raised" || level === "floating";
function AppHeaderMark({ elevation }: { elevation?: Elevation }) {
const raised = rises(useElevation(elevation, "handle"));
return (
<span
className={cn(
"inline-flex size-7 items-center justify-center rounded-lg border border-transparent bg-primary text-primary-foreground",
raised &&
"[background-image:var(--r1-p-face)] shadow-btn-raised-primary [background-origin:border-box]",
)}
>
<ChartLineUpIcon
className="size-[15px]"
/>
</span>
);
}
// Nav pills stand alone (no track): the `pills` Tabs look. Active = --tab-active + 1px border; `raised` makes the active pill a raised secondary button.
function AppHeaderNavItem({
className,
active,
elevation,
...props
}: React.ComponentProps<"a"> & {
active?: boolean;
/** ✦ raised +1 / floating +2 raise the active pill. */
elevation?: Elevation;
}) {
const raised = rises(useElevation(elevation, "control"));
return (
<a
data-slot="app-header-nav-item"
data-active={active ? "" : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"inline-flex h-8 items-center rounded-[7px] border border-transparent px-3 text-[13.5px] font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
"data-[active]:border-border data-[active]:bg-tab-active data-[active]:text-foreground",
raised &&
"data-[active]:border-transparent data-[active]:[background-image:var(--r1-s-face)] data-[active]:shadow-btn-raised-neutral data-[active]:[background-origin:border-box]",
className,
)}
{...props}
/>
);
}
// App top bar (navbar layout): brand + nav pills, then search, network and wallet.
function AppHeader({
className,
logo,
name,
href = "/",
items = [],
active,
search = true,
searchPlaceholder = "Search",
shortcut = "⌘K",
network,
connectLabel = "Connect",
onConnect,
onNetworkClick,
elevation,
...props
}: Omit<React.ComponentProps<"header">, "children"> & {
logo?: React.ReactNode;
name?: React.ReactNode;
href?: string;
items?: AppHeaderItem[];
/** `href` of the active item. */
active?: string;
/** `false` hides the search field. */
search?: boolean;
searchPlaceholder?: string;
shortcut?: string;
/** Secondary button label (e.g. a network or workspace); omit or pass an empty string to hide it. */
network?: string;
connectLabel?: React.ReactNode;
onConnect?: () => void;
onNetworkClick?: () => void;
/** ✦ depth of the header plate; raised +1 / floating +2 also raise the mark, active pill and buttons. */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "surface");
const control = controlLevel(elevation);
return (
<header
data-slot="app-header"
className={cn(
"@container/app-header flex w-full items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-3 text-sm text-card-foreground",
surfaceElevation[level],
className,
)}
{...props}
>
<div className="flex items-center gap-[18px]">
<a href={href} className="flex items-center gap-2.5 whitespace-nowrap">
{logo ?? <AppHeaderMark elevation={control} />}
{name != null ? (
<span className="font-brand text-xl font-semibold tracking-[-0.4px]">
{name}
</span>
) : null}
</a>
<nav
className="flex items-center gap-0.5 max-lg:hidden"
aria-label="App"
>
{items.map((item) => (
<AppHeaderNavItem
key={item.href}
href={item.href}
active={item.href === active}
elevation={control}
>
{item.label}
</AppHeaderNavItem>
))}
</nav>
</div>
<div className="flex items-center gap-2">
{search ? (
<InputGroup className="w-[180px] @max-[960px]/app-header:hidden">
<InputGroupAddon>
<MagnifyingGlassIcon
/>
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="text-[13px]"
/>
<InputGroupAddon align="inline-end">
<Kbd elevation={control}>{shortcut}</Kbd>
</InputGroupAddon>
</InputGroup>
) : null}
{network ? (
<Button
variant="secondary"
elevation={control}
onClick={onNetworkClick}
>
{network}
</Button>
) : null}
<Button elevation={control} onClick={onConnect}>
<WalletIcon
/>
{connectLabel}
</Button>
</div>
</header>
);
}
export type { AppHeaderItem };
export { AppHeader, AppHeaderNavItem };
registry/ui/app-header/AppHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AppHeaderItem } from '.'
import { ChartLineIcon, Search, WalletIcon } from '@lucide/vue'
import { computed } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { Button } from '@/registry/edmi/ui/button'
import { controlLevel, type Elevation, surfaceElevation, useElevation } from '@/registry/edmi/ui/elevation'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/edmi/ui/input-group'
import { Kbd } from '@/registry/edmi/ui/kbd'
import AppHeaderNavItem from './AppHeaderNavItem.vue'
const props = withDefaults(defineProps<{
/** Brand text (or use the `name` slot). */
name?: string
href?: string
items?: AppHeaderItem[]
/** `href` of the active item. */
active?: string
/** `false` hides the search field. */
search?: boolean
searchPlaceholder?: string
shortcut?: string
/** Secondary button label (e.g. a network or workspace; or use the `network` slot); omit to hide it. */
network?: string
/** Primary button label (or use the `connect-label` slot). */
connectLabel?: string
/** ✦ depth of the bar; raised +1 / floating +2 also raise the mark, active pill and buttons */
elevation?: Elevation
class?: HTMLAttributes['class']
}>(), {
href: '/',
items: () => [],
search: true,
searchPlaceholder: 'Search',
shortcut: '⌘K',
connectLabel: 'Connect',
elevation: undefined,
})
const level = useElevation(() => props.elevation, 'surface')
// Controls follow an explicit bar level: raised/floating raise them, flat/sunken keep them flat; auto leaves them to their own role.
const control = computed(() => controlLevel(props.elevation))
const markLevel = useElevation(() => control.value, 'handle')
const markRaised = computed(() => markLevel.value === 'raised' || markLevel.value === 'floating')
const emit = defineEmits<{
(e: 'connect'): void
(e: 'network-click'): void
}>()
</script>
<template>
<!-- App top bar (navbar layout): brand + nav pills, then search, network and wallet. -->
<header
data-slot="app-header"
:class="cn(
'@container/app-header flex w-full items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-3 text-sm text-card-foreground',
surfaceElevation[level],
props.class,
)"
>
<div class="flex items-center gap-[18px]">
<a :href="href" class="flex items-center gap-2.5 whitespace-nowrap">
<slot name="logo">
<span :class="cn('inline-flex size-7 items-center justify-center rounded-lg border border-transparent bg-primary text-primary-foreground', markRaised && '[background-image:var(--r1-p-face)] shadow-btn-raised-primary [background-origin:border-box]')">
<ChartLineIcon class="size-[15px]" />
</span>
</slot>
<span v-if="name || $slots.name" class="font-brand text-xl font-semibold tracking-[-0.4px]"><slot name="name">{{ name }}</slot></span>
</a>
<nav class="flex items-center gap-0.5 max-lg:hidden" aria-label="App">
<AppHeaderNavItem
v-for="item in items"
:key="item.href"
:href="item.href"
:active="item.href === active"
:elevation="control"
>
{{ item.label }}
</AppHeaderNavItem>
</nav>
</div>
<div class="flex items-center gap-2">
<InputGroup v-if="search" class="w-[180px] @max-[960px]/app-header:hidden">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
:placeholder="searchPlaceholder"
:aria-label="searchPlaceholder"
class="text-[13px]"
/>
<InputGroupAddon align="inline-end">
<Kbd :elevation="control">{{ shortcut }}</Kbd>
</InputGroupAddon>
</InputGroup>
<Button v-if="network || $slots.network" variant="secondary" :elevation="control" @click="emit('network-click')">
<slot name="network">{{ network }}</slot>
</Button>
<Button :elevation="control" @click="emit('connect')">
<WalletIcon />
<slot name="connect-label">{{ connectLabel }}</slot>
</Button>
</div>
</header>
</template>
registry/ui/app-header/AppHeaderNavItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
const props = withDefaults(defineProps<{
active?: boolean
/** ✦ raised +1 / floating +2 raise the active pill */
elevation?: Elevation
class?: HTMLAttributes['class']
}>(), { elevation: undefined })
const level = useElevation(() => props.elevation, 'control')
const raised = computed(() => level.value === 'raised' || level.value === 'floating')
</script>
<template>
<!-- Nav pills stand alone (no track): the pills look of Tabs; `elevation` raises the active one. -->
<a
data-slot="app-header-nav-item"
:data-active="active ? '' : undefined"
:aria-current="active ? 'page' : undefined"
:class="cn(
'inline-flex h-8 items-center rounded-[7px] border border-transparent px-3 text-[13.5px] font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
'data-[active]:border-border data-[active]:bg-tab-active data-[active]:text-foreground',
raised && 'data-[active]:border-transparent data-[active]:[background-image:var(--r1-s-face)] data-[active]:shadow-btn-raised-neutral data-[active]:[background-origin:border-box]',
props.class,
)"
>
<slot />
</a>
</template>
registry/ui/app-header/index.ts
export type AppHeaderItem = { label: string; href: string };
export { default as AppHeader } from "./AppHeader.vue";
export { default as AppHeaderNavItem } from "./AppHeaderNavItem.vue";
src/lib/registry/ui/app-header/app-header-nav-item.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes } from "svelte/elements";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
class: className,
active,
elevation = "auto",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes, HTMLAnchorElement> & {
active?: boolean;
/** ✦ raised +1 / floating +2 raise the active pill. */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "control");
const raised = $derived(level.current === "raised" || level.current === "floating");
</script>
<!-- Nav pills = `TabsList variant="pills"` look: flat active pill, `elevation` raises the active pill. -->
<a
bind:this={ref}
data-slot="app-header-nav-item"
data-active={active ? "" : undefined}
aria-current={active ? "page" : undefined}
class={cn(
"inline-flex h-8 items-center rounded-[7px] border border-transparent px-3 text-[13.5px] font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
"data-[active]:border-border data-[active]:bg-tab-active data-[active]:text-foreground",
raised &&
"data-[active]:border-transparent data-[active]:[background-image:var(--r1-s-face)] data-[active]:shadow-btn-raised-neutral data-[active]:[background-origin:border-box]",
className
)}
{...restProps}
>
{@render children?.()}
</a>
src/lib/registry/ui/app-header/app-header.svelte
<script lang="ts" module>
export type AppHeaderItem = { label: string; href: string };
</script>
<script lang="ts">
import ChartLineUpIcon from "phosphor-svelte/lib/ChartLineUp";
import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlass";
import WalletIcon from "phosphor-svelte/lib/Wallet";
import { Button } from "$lib/registry/ui/button/index.js";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "$lib/registry/ui/input-group/index.js";
import { Kbd } from "$lib/registry/ui/kbd/index.js";
import { controlLevel, type Elevation, surfaceElevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import AppHeaderNavItem from "./app-header-nav-item.svelte";
let {
ref = $bindable(null),
class: className,
logo,
name,
href = "/",
items = [],
active,
search = true,
searchPlaceholder = "Search",
shortcut = "⌘K",
network,
connectLabel = "Connect",
onConnect,
onNetworkClick,
elevation = "auto",
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLElement>, "children">> & {
logo?: Snippet;
/** Brand text or a snippet. */
name?: string | Snippet;
href?: string;
items?: AppHeaderItem[];
/** `href` of the active item. */
active?: string;
/** `false` hides the search field. */
search?: boolean;
searchPlaceholder?: string;
shortcut?: string;
/** Secondary button label (e.g. a network or workspace); omit to hide it. */
network?: string | Snippet;
/** Primary button label. */
connectLabel?: string | Snippet;
onConnect?: () => void;
onNetworkClick?: () => void;
/** ✦ depth of the bar; raised +1 / floating +2 also raise the mark, active pill and buttons. */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "surface");
// Controls follow an explicit bar level (flat/sunken keep them flat); auto leaves them to their own role.
const control = $derived<Elevation | undefined>(controlLevel(elevation));
const markLevel = useElevation(() => control ?? "auto", "handle");
const markRaised = $derived(markLevel.current === "raised" || markLevel.current === "floating");
</script>
<!-- App top bar (navbar layout): brand + nav pills, then search, network and wallet. -->
<header
bind:this={ref}
data-slot="app-header"
class={cn(
"@container/app-header flex w-full items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-3 text-sm text-card-foreground",
surfaceElevation[level.current],
className
)}
{...restProps}
>
<div class="flex items-center gap-[18px]">
<a {href} class="flex items-center gap-2.5 whitespace-nowrap">
{#if logo}
{@render logo()}
{:else}
<span
class={cn(
"inline-flex size-7 items-center justify-center rounded-lg border border-transparent bg-primary text-primary-foreground",
markRaised &&
"[background-image:var(--r1-p-face)] shadow-btn-raised-primary [background-origin:border-box]"
)}
>
<ChartLineUpIcon
class="size-[15px]"
/>
</span>
{/if}
{#if name}
<span class="font-brand text-xl font-semibold tracking-[-0.4px]">
{#if typeof name === "string"}{name}{:else}{@render name()}{/if}
</span>
{/if}
</a>
<nav class="flex items-center gap-0.5 max-lg:hidden" aria-label="App">
{#each items as item (item.href)}
<AppHeaderNavItem href={item.href} active={item.href === active} elevation={control}>
{item.label}
</AppHeaderNavItem>
{/each}
</nav>
</div>
<div class="flex items-center gap-2">
{#if search}
<InputGroup class="w-[180px] @max-[960px]/app-header:hidden">
<InputGroupAddon>
<MagnifyingGlassIcon
/>
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
class="text-[13px]"
/>
<InputGroupAddon align="inline-end">
<Kbd elevation={control}>{shortcut}</Kbd>
</InputGroupAddon>
</InputGroup>
{/if}
{#if network}
<Button variant="secondary" elevation={control} onclick={onNetworkClick}>
{#if typeof network === "string"}{network}{:else}{@render network()}{/if}
</Button>
{/if}
<Button elevation={control} onclick={onConnect}>
<WalletIcon
/>
{#if typeof connectLabel === "string"}{connectLabel}{:else}{@render connectLabel()}{/if}
</Button>
</div>
</header>
src/lib/registry/ui/app-header/index.ts
export { type AppHeaderItem, default as AppHeader } from "./app-header.svelte";
export { default as AppHeaderNavItem } from "./app-header-nav-item.svelte";

See the Elevation guide for the levels, roles and layered mode.

Flat by default. elevation sets the depth of the bar; raised (+1) and floating (+2) also raise the mark, the active nav pill (only the active pill rises) and the buttons. In a layered ElevationProvider the bar is a raised surface and its controls follow their own roles.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Raised (+1)

⌘K

Floating (+2)

⌘K

Raised (+1)

⌘K

Floating (+2)

⌘K

Flat (0)

⌘K

Raised (+1)

⌘K

Floating (+2)

⌘K
Part Prop Type Default
AppHeader logo / name / href as SiteHeader (name is not rendered when empty) none / none / "/"
items { label, href }[] []
active string (href of the active pill) none
search boolean true
searchPlaceholder / shortcut string "Search" / "⌘K"
network secondary button label, e.g. a network or workspace (React string, Vue string or network slot, Svelte string | Snippet); empty hides it none
connectLabel ReactNode (Vue connect-label slot, Svelte string | Snippet) "Connect"
onConnect / onNetworkClick () => void (Vue emits connect / network-click) none
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
AppHeaderNavItem active, elevation exported for custom navs
  • Nav pills stand alone (no track), active pill is --tab-active + 1px border (the pills look); with elevation="raised" it becomes a raised secondary button.