Menu

Change language

GitHub repo

Header — Floating Pill

blockmarketing

Detached pill navbar that floats above the page with a blurred surface, a sliding active indicator that tracks the current link, and a compact CTA on the right.

Installation

$npx uipkge-ng@latest add header-floating-pill
Manifest:https://uipkge.dev/r/angular/header-floating-pill.jsonInstalls to:src/app/components/blocks/header-floating-pill/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

FloatingPillLink
interface FloatingPillLink {
  label: string
  href: string
}
PillIndicator
interface PillIndicator {
  left: number
  width: number
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/header-floating-pill/header-floating-pill.component.ts5.5 kB
    // Detached pill navbar floating above the page with a blurred surface, a sliding
    // active indicator tracking the current link, and a compact CTA on the right.
    // Port of the Vue/React block 1:1 — the indicator is measured from the live element
    // so it fits any label length or font, with no hard-coded widths to drift.
    import {
      AfterViewInit,
      ChangeDetectionStrategy,
      Component,
      ElementRef,
      Input,
      OnDestroy,
      ViewChild,
      signal,
    } from '@angular/core'
    import { Layers, LucideAngularModule, Menu } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiSheetCloseComponent,
      UiSheetComponent,
      UiSheetContentComponent,
      UiSheetHeaderComponent,
      UiSheetTitleComponent,
    } from '@/ui/sheet/sheet.component'
    
    export interface FloatingPillLink {
      label: string
      href: string
    }
    
    export interface PillIndicator {
      left: number
      width: number
    }
    
    const links: FloatingPillLink[] = [
      { label: 'Product', href: '#product' },
      { label: 'Pricing', href: '#pricing' },
      { label: 'Customers', href: '#customers' },
      { label: 'Docs', href: '#docs' },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-header-floating-pill, [ui-header-floating-pill]',
      standalone: true,
      // The block renders its own root <div>: keep this wrapper out of the layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiButtonComponent,
        UiSheetCloseComponent,
        UiSheetComponent,
        UiSheetContentComponent,
        UiSheetHeaderComponent,
        UiSheetTitleComponent,
      ],
      template: `
        <div data-slot="header-floating-pill" [class]="rootClass">
          <header
            class="border-border bg-card/85 pointer-events-auto mx-auto flex h-14 max-w-4xl items-center gap-3 rounded-full border px-3 shadow-sm backdrop-blur"
          >
            <a href="#top" class="flex shrink-0 items-center gap-2 pl-2 font-semibold tracking-tight">
              <lucide-icon [img]="Layers" class="text-primary size-4" />
              Verity
            </a>
    
            <nav #navEl class="relative hidden items-center md:flex" aria-label="Primary">
              <span
                class="bg-muted absolute inset-y-1 rounded-full transition-colors duration-200 ease-out"
                [style.left.px]="indicator().left"
                [style.width.px]="indicator().width"
                aria-hidden="true"
              ></span>
              @for (link of pillLinks; track link.href; let i = $index) {
                <a
                  [href]="link.href"
                  data-pill-link
                  class="focus-visible:ring-ring relative rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
                  [class]="i === active() ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'"
                  [attr.aria-current]="i === active() ? 'page' : null"
                  (mouseenter)="measure(i)"
                  (mouseleave)="measure()"
                  (focus)="measure(i)"
                  (blur)="measure()"
                  (click)="setActive(i)"
                >
                  {{ link.label }}
                </a>
              }
            </nav>
    
            <button ui-button size="sm" class="ml-auto rounded-full">Start free</button>
    
            <div ui-sheet [open]="mobileOpen()" (openChange)="mobileOpen.set($event)">
              <button
                ui-button
                variant="ghost"
                size="icon"
                class="rounded-full md:hidden"
                aria-label="Open navigation menu"
                (click)="mobileOpen.set(true)"
              >
                <lucide-icon [img]="Menu" class="size-5" />
              </button>
              <div ui-sheet-content side="top">
                <div ui-sheet-header>
                  <h2 ui-sheet-title>Menu</h2>
                </div>
                <nav class="mt-6 grid gap-1" aria-label="Mobile">
                  @for (link of pillLinks; track link.href; let i = $index) {
                    <a
                      [href]="link.href"
                      class="hover:bg-muted rounded-md px-3 py-2 text-sm font-medium transition-colors"
                      (click)="selectMobile(i)"
                    >
                      {{ link.label }}
                    </a>
                  }
                </nav>
              </div>
            </div>
          </header>
        </div>
      `,
    })
    export class UiHeaderFloatingPillComponent implements AfterViewInit, OnDestroy {
      protected readonly Layers = Layers
      protected readonly Menu = Menu
    
      protected readonly pillLinks = links
    
      @ViewChild('navEl') navEl?: ElementRef<HTMLElement>
    
      @Input('class') className?: string
    
      readonly active = signal(0)
      readonly mobileOpen = signal(false)
      readonly indicator = signal<PillIndicator>({ left: 0, width: 0 })
    
      private observer?: ResizeObserver
    
      ngAfterViewInit(): void {
        this.measure()
        const nav = this.navEl?.nativeElement
        if (typeof ResizeObserver !== 'undefined' && nav) {
          this.observer = new ResizeObserver(() => this.measure())
          this.observer.observe(nav)
        }
      }
    
      ngOnDestroy(): void {
        this.observer?.disconnect()
        this.observer = undefined
      }
    
      setActive(index: number): void {
        this.active.set(index)
      }
    
      selectMobile(index: number): void {
        this.active.set(index)
        this.mobileOpen.set(false)
      }
    
      measure(index = this.active()): void {
        const items = this.navEl?.nativeElement.querySelectorAll<HTMLElement>('[data-pill-link]')
        const el = items?.[index]
        if (!el) return
        this.indicator.set({ left: el.offsetLeft, width: el.offsetWidth })
      }
    
      get rootClass(): string {
        return cn('pointer-events-none sticky top-0 z-40 px-4 pt-4', this.className)
      }
    }
    
  • src/app/components/blocks/header-floating-pill/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/header-floating-pill.json