Header
Added 14 October 2026
React
Codecomponents/react/header-demo.tsx
import {useState} from 'react';
import {Header, HeaderActions, HeaderLogo, Avatar, AvatarFallback, Button} from '@stroeer-labs/design-library/react';
import {GlobeIcon, ChevronDownIcon} from 'lucide-react';
// The Header is Tier-1 chrome only — logo slot + actions slot. Everything inside the slots
// (the switcher, the avatar) is composed by the app from OTHER primitives; the Header owns none
// of it. The first demo drops it into an "app frame" — header on top, body scrolling beneath — and
// wires an elevation-on-scroll shadow IN THE DEMO (not the component) to show how an app layers
// behaviour on top of the shared chrome. (The component also ships `sticky top-0` for apps that
// scroll the whole page instead of an inner region.)
// A stand-in wordmark so the demo needs no logo asset. Real apps drop their routed <a><img/></a>
// into <HeaderLogo>. The decorative tile is aria-hidden so the link's accessible name resolves to
// the visible "Ströer" (WCAG 2.5.3 Label in Name — no aria-label overriding the visible text).
function Wordmark() {
return (
<a href="/" className="flex items-center gap-2">
<span
aria-hidden="true"
className="flex size-7 items-center justify-center rounded-md bg-brand-fixed text-sm font-bold text-brand-fixed-foreground"
>
S
</span>
<span className="text-sm font-semibold tracking-tight">Ströer</span>
</a>
);
}
function Actions() {
return (
<>
<Button variant="ghost" size="sm" className="gap-1.5">
<GlobeIcon className="size-4" />
<span className="uppercase">EN</span>
<ChevronDownIcon className="size-3 opacity-60" />
</Button>
<Avatar>
<AvatarFallback>AT</AvatarFallback>
</Avatar>
</>
);
}
const rows = [1, 2, 3, 4, 5, 6, 7, 8];
// A fake app-shell frame: the Header sits ABOVE the scroll region (flex column), and only the body
// scrolls — so the scrollbar starts below the header, exactly like a real app. The header casts its
// elevation shadow down over the scrolling content.
function AppFrame() {
const [scrolled, setScrolled] = useState(false);
return (
<div className="flex h-96 flex-col overflow-hidden rounded-lg border border-border bg-background">
{/* The library owns the elevation VISUAL (`elevated` → shadow-glass token, dark-aware);
the app owns the BEHAVIOUR — this onScroll → state wiring — because what scrolls
(window vs inner container) is app-specific. */}
<Header elevated={scrolled}>
<HeaderLogo>
<Wordmark />
</HeaderLogo>
<HeaderActions>
<Actions />
</HeaderActions>
</Header>
<div className="flex-1 overflow-y-auto p-4 md:p-6" onScroll={(e) => setScrolled(e.currentTarget.scrollTop > 8)}>
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-lg font-semibold tracking-tight">Campaigns</h3>
<p className="text-sm text-muted-foreground">Scroll inside the frame — the header stays on top and gains a shadow.</p>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{label: 'Active', value: '12'},
{label: 'Impressions', value: '48.2k'},
{label: 'Budget', value: '€9,400'},
].map((stat) => (
<div key={stat.label} className="rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums">{stat.value}</p>
</div>
))}
</div>
<div className="space-y-3">
{rows.map((n) => (
<div key={n} className="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<div className="space-y-2">
<div className="h-3 w-40 rounded bg-muted" />
<div className="h-2.5 w-24 rounded bg-muted/60" />
</div>
<div className="h-6 w-16 rounded-full bg-muted" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function HeaderDemo() {
return (
<div className="space-y-8">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">In an app frame — header fixed, body scrolls (+ app-wired elevation)</p>
<AppFrame />
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Anatomy — logo slot + action cluster</p>
{/* rounded-lg + overflow-hidden just to frame the full-bleed bar inside the demo card */}
<div className="overflow-hidden rounded-lg border border-border">
<Header>
<HeaderLogo>
<Wordmark />
</HeaderLogo>
<HeaderActions>
<Actions />
</HeaderActions>
</Header>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Elevated — static prop, shadow always on (no change in dark: border separates)</p>
<div className="overflow-hidden rounded-lg border border-border">
<Header elevated>
<HeaderLogo>
<Wordmark />
</HeaderLogo>
<HeaderActions>
<Actions />
</HeaderActions>
</Header>
{/* room below the bar so the downward shadow can render inside the clipped frame */}
<div aria-hidden="true" className="h-16 bg-background" />
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Minimal — logo only (empty actions slot)</p>
<div className="overflow-hidden rounded-lg border border-border">
<Header>
<HeaderLogo>
<Wordmark />
</HeaderLogo>
<HeaderActions />
</Header>
</div>
</div>
</div>
);
}Angular
Codecomponents/angular/header-demo.ts
import {Component, signal} from '@angular/core';
import {NgIcon, provideIcons} from '@ng-icons/core';
import {lucideGlobe, lucideChevronDown} from '@ng-icons/lucide';
import {HeaderImports, HlmAvatarImports, HlmButtonImports} from '@stroeer-labs/design-library/angular';
// Demo-local helpers so the three showcases can't drift — mirrors the React demo's Wordmark()/
// Actions() extraction. Attribute selectors keep the content on the consumer's own element, so the
// dlHeaderLogo/dlHeaderActions slot directives and the helper share one node (no extra wrapper).
// The decorative tile is aria-hidden so the link's accessible name resolves to the visible
// "Ströer" (WCAG 2.5.3 Label in Name — no aria-label overriding the visible text).
@Component({
selector: 'a[dlDemoWordmark]',
standalone: true,
template: `
<span aria-hidden="true" class="flex size-7 items-center justify-center rounded-md bg-brand-fixed text-sm font-bold text-brand-fixed-foreground">S</span>
<span class="text-sm font-semibold tracking-tight">Ströer</span>
`,
})
class DemoWordmark {}
@Component({
selector: 'div[dlDemoHeaderActions]',
standalone: true,
imports: [HlmAvatarImports, HlmButtonImports, NgIcon],
viewProviders: [provideIcons({lucideGlobe, lucideChevronDown})],
template: `
<button hlmBtn variant="ghost" size="sm" class="gap-1.5">
<ng-icon name="lucideGlobe" class="size-4" />
<span class="uppercase">EN</span>
<ng-icon name="lucideChevronDown" class="size-3 opacity-60" />
</button>
<hlm-avatar>
<span hlmAvatarFallback>AT</span>
</hlm-avatar>
`,
})
class DemoHeaderActions {}
// Mirrors the React header demo exactly (../react/header-demo.tsx). The Header is Tier-1 chrome
// only — logo slot + actions slot; the switcher/avatar inside are composed from OTHER primitives.
// The first demo puts it in a scrollable "app frame" so its built-in `sticky top-0` is visible,
// and wires the elevation-on-scroll shadow IN THE DEMO (not the component) — Tier-3 app behaviour.
@Component({
selector: 'dl-header-demo',
standalone: true,
imports: [HeaderImports, DemoWordmark, DemoHeaderActions],
template: `
<div class="space-y-8">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">In an app frame — header fixed, body scrolls (+ app-wired elevation)</p>
<!-- A fake app-shell frame: the Header sits ABOVE the scroll region (flex column) and
only the body scrolls — scrollbar starts below the header, like a real app. The
library owns the elevation VISUAL (\`elevated\` → shadow-glass token, dark-aware);
the app owns the BEHAVIOUR — this (scroll) → signal wiring — because what scrolls
(window vs inner container) is app-specific. -->
<div class="flex h-96 flex-col overflow-hidden rounded-lg border border-border bg-background">
<dl-header [elevated]="scrolled()">
<a dlDemoWordmark dlHeaderLogo href="/" class="gap-2"></a>
<div dlDemoHeaderActions dlHeaderActions></div>
</dl-header>
<div class="flex-1 overflow-y-auto p-4 md:p-6" (scroll)="onScroll($event)">
<div class="space-y-6">
<div class="space-y-1">
<h3 class="text-lg font-semibold tracking-tight">Campaigns</h3>
<p class="text-sm text-muted-foreground">Scroll inside the frame — the header stays on top and gains a shadow.</p>
</div>
<div class="grid grid-cols-3 gap-3">
@for (stat of stats; track stat.label) {
<div class="rounded-lg border border-border bg-card p-4">
<p class="text-xs text-muted-foreground">{{ stat.label }}</p>
<p class="mt-1 font-mono text-2xl font-semibold tabular-nums">{{ stat.value }}</p>
</div>
}
</div>
<div class="space-y-3">
@for (n of rows; track n) {
<div class="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<div class="space-y-2">
<div class="h-3 w-40 rounded bg-muted"></div>
<div class="h-2.5 w-24 rounded bg-muted/60"></div>
</div>
<div class="h-6 w-16 rounded-full bg-muted"></div>
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Anatomy — logo slot + action cluster</p>
<div class="overflow-hidden rounded-lg border border-border">
<dl-header>
<a dlDemoWordmark dlHeaderLogo href="/" class="gap-2"></a>
<div dlDemoHeaderActions dlHeaderActions></div>
</dl-header>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Elevated — static prop, shadow always on (no change in dark: border separates)</p>
<div class="overflow-hidden rounded-lg border border-border">
<dl-header elevated>
<a dlDemoWordmark dlHeaderLogo href="/" class="gap-2"></a>
<div dlDemoHeaderActions dlHeaderActions></div>
</dl-header>
<!-- room below the bar so the downward shadow can render inside the clipped frame -->
<div aria-hidden="true" class="h-16 bg-background"></div>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Minimal — logo only (empty actions slot)</p>
<div class="overflow-hidden rounded-lg border border-border">
<dl-header>
<a dlDemoWordmark dlHeaderLogo href="/" class="gap-2"></a>
<div dlHeaderActions></div>
</dl-header>
</div>
</div>
</div>
`,
})
export class HeaderDemoComponent {
protected readonly scrolled = signal(false);
protected readonly stats = [
{label: 'Active', value: '12'},
{label: 'Impressions', value: '48.2k'},
{label: 'Budget', value: '€9,400'},
];
protected readonly rows = [1, 2, 3, 4, 5, 6, 7, 8];
protected onScroll(event: Event) {
this.scrolled.set((event.target as HTMLElement).scrollTop > 8);
}
}