Skip to content
Navigation

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);
    }
}