Skip to content
jswnthui

Navigation

Filter Tabs

Category tabs with a green underline that glides to whichever one is active.

Morni
Morni
Dreamsoftplay
Dreamsoftplay
NexusOS
NexusOS
Sands & Souls
Sands & Souls
Tentist
Tentist
Orbitcorp
Orbitcorp

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/filter-tabs.tsx
    'use client'
    
    import { useEffect, useRef } from 'react'
    import gsap from 'gsap'
    
    interface FilterTabsProps {
      categories: string[]
      activeCategory: string
      onCategoryChange: (category: string) => void
    }
    
    export default function FilterTabs({ categories, activeCategory, onCategoryChange }: FilterTabsProps) {
      const tabsRef = useRef<HTMLDivElement>(null)
      const underlineRef = useRef<HTMLDivElement>(null)
      const tabButtonsRef = useRef<(HTMLButtonElement | null)[]>([])
    
      useEffect(() => {
        const updateUnderline = () => {
          const activeButton = tabButtonsRef.current.find(
            (btn) => btn?.textContent?.toUpperCase() === activeCategory.toUpperCase()
          )
    
          if (!activeButton || !underlineRef.current) return
    
          const rect = activeButton.getBoundingClientRect()
          const parentRect = tabsRef.current?.getBoundingClientRect()
    
          if (!parentRect) return
    
          const left = rect.left - parentRect.left
          const width = rect.width
    
          gsap.to(underlineRef.current, {
            width,
            left,
            duration: 0.4,
            ease: 'power3.out',
          })
        }
    
        updateUnderline()
        window.addEventListener('resize', updateUnderline)
        return () => window.removeEventListener('resize', updateUnderline)
      }, [activeCategory])
    
      return (
        <div ref={tabsRef} className="relative flex items-center gap-6 md:gap-8 border-b border-white/10">
          {categories.map((category, i) => (
            <button
              key={category}
              ref={(el) => {
                tabButtonsRef.current[i] = el
              }}
              onClick={() => onCategoryChange(category)}
              className={`relative pb-4 font-kine-body text-sm font-bold uppercase tracking-widest transition-colors duration-300 md:text-base ${
                activeCategory === category ? 'text-kine-primary' : 'text-kine-accent/60 hover:text-kine-accent'
              }`}
            >
              {category}
            </button>
          ))}
    
          {/* Animated underline — positioned absolutely under the active tab,
              animated via GSAP on category change. */}
          <div
            ref={underlineRef}
            className="absolute bottom-0 h-1 bg-kine-primary"
            style={{ left: 0, width: 0 }}
          />
        </div>
      )
    }
    styles/kinematics.css
    /* Kinematic Studio's palette and type, for the components ported from its
       site. Namespaced as kine-* so they sit beside this library's own tokens —
       Kinematics calls its off-white "accent", which here is already the blue.
       Import this from the stylesheet that imports Tailwind, so the @theme
       block turns into utilities: bg-kine-bg, text-kine-primary/70,
       font-kine-display and so on. */
    @theme {
      --color-kine-bg: #0f1113;
      --color-kine-primary: #ceff00;
      --color-kine-surface: #2b2e34;
      --color-kine-accent: #e2e8f0;
    
      /* Audiowide for headlines, Geo for body copy. Geo breaks up at small
         sizes, so tiny labels use the plain sans instead. */
      --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif;
      --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif;
    
      /* Morni, the restaurant client in the Emoji Cursor demo. */
      --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif;
      --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif;
    }
    
    /* Manifesto emoji markers — the box grows from square to rectangle as scroll
       reaches it; once mostly grown, the emoji starts rolling, paused until then
       via animation-play-state set inline. */
    @keyframes manifesto-emoji-roll {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* Expertise cards — small, always-looping visuals tying each card's
       animation to its own subject (motion, layout, load speed, ranking,
       language). CSS keyframes rather than GSAP, since they run for as long as
       the card is mounted. */
    @keyframes expertise-dash {
      to {
        stroke-dashoffset: -24;
      }
    }
    .expertise-flow-line {
      stroke-dasharray: 6 6;
      animation: expertise-dash 1s linear infinite;
    }
    
    @keyframes expertise-block-a {
      0%, 100% { width: 85%; }
      50% { width: 45%; }
    }
    @keyframes expertise-block-b {
      0%, 100% { width: 55%; }
      50% { width: 90%; }
    }
    @keyframes expertise-block-c {
      0%, 100% { width: 70%; }
      50% { width: 30%; }
    }
    .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; }
    .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; }
    .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; }
    
    @keyframes expertise-fill {
      0% { width: 6%; opacity: 0.5; }
      70% { width: 100%; opacity: 1; }
      85% { width: 100%; opacity: 1; }
      100% { width: 6%; opacity: 0.5; }
    }
    .expertise-fill-bar {
      animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
    }
    
    @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } }
    @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } }
    @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } }
    @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } }
    .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; }
    .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; }
    .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; }
    .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; }
    
    @keyframes expertise-lang-cross {
      0%, 45% { opacity: 1; transform: translateY(0); }
      50%, 95% { opacity: 0; transform: translateY(-6px); }
      100% { opacity: 1; transform: translateY(0); }
    }
    @keyframes expertise-lang-cross-alt {
      0%, 45% { opacity: 0; transform: translateY(6px); }
      50%, 95% { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(6px); }
    }
    .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; }
    .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }

Props

PropTypeDefaultDescription
categoriesstring[]—The tabs, in order.
activeCategorystring—The selected tab.
onCategoryChange(category: string) => void—Called with the tab that was clicked.