Skip to content

Collection 01 / Interactive

Feature Switcher.

Keyboard-controlled tabs with an animated visual stage. Show what makes your process different.

Live playground

Accent

How I work

From the first idea to the final pixel.

Find the right direction.

Start with the people, the problem, and the possibility. A clear foundation for everything that follows.

import { FeatureSwitcher } from "@/components/gear5/feature-switcher";

export default function Example() {
  return <FeatureSwitcher accent={"#d9fc87"} />;
}

Make it yours.

npx shadcn@latest add https://gear5-ui.vercel.app/r/feature-switcher.json

Copies editable source into your project. Setup guide · React and Tailwind CSS 4 required.

Put it to work.

import { FeatureSwitcher } from "@/components/gear5/feature-switcher";
export default function Features() {
  return <FeatureSwitcher title="Your process, made visible." features={[
    { id: "design", label: "Design", title: "Every detail matters.",
      description: "A visual system with character.", visual: <div>Your product preview</div> },
    { id: "build", label: "Build", title: "Ready for the real world.",
      description: "From idea to launch.", visual: <div>Your second preview</div> },
  ]} />;
}

Good to know.

  • Arrow keys move and select tabs; Home and End reach the first and last. Direction follows the surrounding layout.
  • Pass a React node as each feature’s visual to display your product preview.
  • The default bars are decorative artwork, not analytics or real measurements.

Source & props.

Installer-ready imports. For manual installation, copy this file into components/gear5/feature-switcher.tsx and add the helpers below at their shown paths.

"use client";

import {
  useId,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

export interface SwitcherFeature {
  id: string;
  label: string;
  title: string;
  description: string;
  visual?: ReactNode;
}
const defaults: SwitcherFeature[] = [
  {
    id: "strategy",
    label: "Strategy",
    title: "Find the right direction.",
    description:
      "Start with the people, the problem, and the possibility. A clear foundation for everything that follows.",
  },
  {
    id: "design",
    label: "Design",
    title: "Make every detail count.",
    description:
      "A considered visual language, thoughtful interactions, and an experience that feels unmistakably yours.",
  },
  {
    id: "build",
    label: "Development",
    title: "Bring it into the real world.",
    description:
      "Responsive, accessible, and ready to ship. Crafted in code with the same care as the first sketch.",
  },
];
export interface FeatureSwitcherProps {
  features?: SwitcherFeature[];
  title?: string;
  eyebrow?: string;
  accent?: string;
  className?: string;
}

/** Tabs use roving focus; arrows follow the computed writing direction. */
export function FeatureSwitcher({
  features = defaults,
  title = "From the first idea to the final pixel.",
  eyebrow = "How I work",
  accent = "#d9fc87",
  className = "",
}: FeatureSwitcherProps) {
  const id = useId();
  const [selected, setSelected] = useState(0);
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const active = Math.min(selected, Math.max(0, features.length - 1));
  const feature = features[active];
  return (
    <section
      style={{ "--g5-accent": accent } as CSSProperties}
      className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
    >
      <p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
        {eyebrow}
      </p>
      <h2 className="mt-3 max-w-xl text-3xl font-medium tracking-[-.05em] sm:text-4xl">
        {title}
      </h2>
      {feature && (
        <>
          <div
            role="tablist"
            aria-label={title}
            className="mt-8 flex flex-wrap gap-2"
          >
            {features.map((item, index) => (
              <button
                key={item.id}
                ref={(node) => {
                  buttons.current[index] = node;
                }}
                id={`${id}-tab-${index}`}
                type="button"
                role="tab"
                aria-selected={active === index}
                aria-controls={`${id}-panel`}
                tabIndex={active === index ? 0 : -1}
                onClick={() => setSelected(index)}
                onKeyDown={(event) => {
                  const rtl =
                    getComputedStyle(event.currentTarget).direction === "rtl";
                  let next: number;
                  if (event.key === "Home") next = 0;
                  else if (event.key === "End") next = features.length - 1;
                  else if (event.key === "ArrowRight")
                    next =
                      (index + (rtl ? -1 : 1) + features.length) %
                      features.length;
                  else if (event.key === "ArrowLeft")
                    next =
                      (index + (rtl ? 1 : -1) + features.length) %
                      features.length;
                  else return;
                  event.preventDefault();
                  setSelected(next);
                  buttons.current[next]?.focus();
                }}
                className={`min-h-11 rounded-full border px-5 py-2 text-sm transition-colors motion-reduce:transition-none ${active === index ? "border-transparent bg-[var(--g5-accent)] text-[#111214]" : "border-white/20 text-[#c2c4bd] hover:bg-white/5"}`}
              >
                {item.label}
              </button>
            ))}
          </div>
          <div
            id={`${id}-panel`}
            role="tabpanel"
            aria-labelledby={`${id}-tab-${active}`}
            tabIndex={0}
            className="mt-5 grid items-center gap-6 rounded-2xl border border-white/15 bg-[#181a1c] p-6 sm:grid-cols-2"
          >
            <div>
              <p
                aria-hidden="true"
                className="mb-6 font-mono text-xs text-[var(--g5-accent)]"
              >
                0{active + 1} / 0{features.length}
              </p>
              <h3 className="text-2xl font-medium tracking-[-.04em]">
                {feature.title}
              </h3>
              <p className="mt-3 text-sm leading-7 text-[#b4b6af]">
                {feature.description}
              </p>
            </div>
            {feature.visual ?? (
              <div
                aria-hidden="true"
                className="flex aspect-[4/3] items-end gap-3 rounded-xl bg-[#232629] p-8"
              >
                {[35, 65, 48, 90, 72].map((height, i) => (
                  <div
                    key={i}
                    style={{ height: `${((height + active * 19) % 75) + 20}%` }}
                    className={`flex-1 rounded-t-lg transition-[height] duration-500 motion-reduce:transition-none ${i === active + 1 ? "bg-[var(--g5-accent)]" : "bg-white/15"}`}
                  />
                ))}
              </div>
            )}
          </div>
        </>
      )}
    </section>
  );
}

VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.