Mikkel Manniche
Dansk English Deutsch

Navigation

Continuous tabs

Tabs, bei denen eine Pille zum gewählten Tab gleitet, mit Pfeiltasten wie eine echte Tab-Liste.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

continuous-tabs-demo.tsx
import { ContinuousTabs } from '@/components/continuous-tabs'

export default function ContinuousTabsDemo() {
  return (
    <div className="flex justify-center">
      <ContinuousTabs
        label="Orders"
        tabs={[
          { id: 'all', label: 'All' },
          { id: 'open', label: 'Open' },
          { id: 'shipped', label: 'Shipped' },
          { id: 'returned', label: 'Returned' },
        ]}
      />
    </div>
  )
}

Quellcode

continuous-tabs.tsx
// Based on Watermelon UI's “Continuous tabs” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as an ARIA tablist with arrow keys.
import { LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useRef, useState, type KeyboardEvent } from 'react'
import { cn } from '@/lib/utils'

export type ContinuousTab = { id: string; label: string }

export type ContinuousTabsProps = {
  tabs: ContinuousTab[]
  /** Controlled active tab. */
  value?: string
  defaultValue?: string
  onChange?: (id: string) => void
  /** Read aloud for the whole row, e.g. "Sections". */
  label?: string
  className?: string
}

/** Tabs where a pill slides to the one you pick. Arrow keys, Home and End move between them. */
export function ContinuousTabs({ tabs, value, defaultValue, onChange, label, className }: ContinuousTabsProps) {
  const [own, setOwn] = useState(defaultValue ?? tabs[0]?.id)
  const active = value ?? own
  const group = useId()
  const refs = useRef<(HTMLButtonElement | null)[]>([])

  const pick = (id: string) => {
    setOwn(id)
    onChange?.(id)
  }

  const onKey = (e: KeyboardEvent, i: number) => {
    const n = tabs.length
    const to = ({ ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 } as Record<string, number>)[e.key]
    if (to === undefined) return
    e.preventDefault()
    refs.current[to]?.focus()
    pick(tabs[to].id)
  }

  return (
    <MotionConfig reducedMotion="user">
      <LayoutGroup id={group}>
        <div role="tablist" aria-label={label} className={cn('inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-2xl border bg-card p-1.5 shadow-sm', className)}>
          {tabs.map((tab, i) => {
            const on = tab.id === active
            return (
              <button
                key={tab.id}
                ref={(el) => {
                  refs.current[i] = el
                }}
                type="button"
                role="tab"
                aria-selected={on}
                tabIndex={on ? 0 : -1}
                onClick={() => pick(tab.id)}
                onKeyDown={(e) => onKey(e, i)}
                className="relative min-h-11 shrink-0 rounded-xl px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {on && (
                  <motion.span
                    layoutId="pill"
                    transition={{ type: 'spring', stiffness: 380, damping: 32 }}
                    className="absolute inset-0 rounded-xl bg-foreground"
                  />
                )}
                <span className={cn('relative transition-colors duration-200', on ? 'text-background' : 'text-muted-foreground hover:text-foreground')}>
                  {tab.label}
                </span>
              </button>
            )
          })}
        </div>
      </LayoutGroup>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/continuous-tabs.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/continuous-tabs

Abhängigkeiten

Dateien

  • continuous-tabs.tsx