Web InnoventixFreeCode

Scrollable Tab

Original · free

horizontally scrollable tabs

byWeb InnoventixReact + Tailwind
tabscrollabletabs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/tab-scrollable.json
tab-scrollable.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Accent = "indigo" | "emerald" | "amber" | "rose" | "sky" | "violet";

type Story = { title: string; meta: string };

type NewsTab = {
  id: string;
  label: string;
  accent: Accent;
  kicker: string;
  featured: { title: string; dek: string; byline: string; read: string };
  more: Story[];
};

const ACCENT: Record<
  Accent,
  { dot: string; bar: string; kicker: string; edge: string }
> = {
  indigo: {
    dot: "bg-indigo-400",
    bar: "bg-indigo-500",
    kicker: "text-indigo-600 dark:text-indigo-400",
    edge: "border-indigo-500",
  },
  emerald: {
    dot: "bg-emerald-400",
    bar: "bg-emerald-500",
    kicker: "text-emerald-600 dark:text-emerald-400",
    edge: "border-emerald-500",
  },
  amber: {
    dot: "bg-amber-400",
    bar: "bg-amber-500",
    kicker: "text-amber-600 dark:text-amber-400",
    edge: "border-amber-500",
  },
  rose: {
    dot: "bg-rose-400",
    bar: "bg-rose-500",
    kicker: "text-rose-600 dark:text-rose-400",
    edge: "border-rose-500",
  },
  sky: {
    dot: "bg-sky-400",
    bar: "bg-sky-500",
    kicker: "text-sky-600 dark:text-sky-400",
    edge: "border-sky-500",
  },
  violet: {
    dot: "bg-violet-400",
    bar: "bg-violet-500",
    kicker: "text-violet-600 dark:text-violet-400",
    edge: "border-violet-500",
  },
};

const TABS: NewsTab[] = [
  {
    id: "top",
    label: "Top Stories",
    accent: "indigo",
    kicker: "Front page",
    featured: {
      title: "Fusion startup keeps plasma stable for a record 43 minutes",
      dek: "The milestone at the Hefei test reactor edges net-gain energy closer to a grid-ready timeline than any run before it.",
      byline: "By Dana Whitfield",
      read: "6 min read",
    },
    more: [
      { title: "City council approves overnight transit pilot on three lines", meta: "Local · 21 min ago" },
      { title: "What the new export rules mean for small manufacturers", meta: "Explainer · 1 hr ago" },
    ],
  },
  {
    id: "world",
    label: "World",
    accent: "emerald",
    kicker: "Across the globe",
    featured: {
      title: "Coalition talks stall as parties clash over the budget ceiling",
      dek: "Negotiators in The Hague broke for the night without a deal on next year's spending cap, pushing a decision to the weekend.",
      byline: "By Marcus Lindqvist",
      read: "5 min read",
    },
    more: [
      { title: "Drought pushes Horn of Africa harvest to a decade low", meta: "Analysis · 2 hr ago" },
      { title: "Border rail link reopens after an eight-year closure", meta: "Dispatch · 3 hr ago" },
    ],
  },
  {
    id: "business",
    label: "Business",
    accent: "amber",
    kicker: "Money & work",
    featured: {
      title: "Retail chains bet on smaller stores as downtown rents climb",
      dek: "The flagship format shrinks to 4,000 square feet as brands chase foot traffic without carrying the overhead of a full department floor.",
      byline: "By Priya Nair",
      read: "4 min read",
    },
    more: [
      { title: "Four-day week trial reports steady output after one year", meta: "Workplace · 45 min ago" },
      { title: "Freight rates cool as port backlogs finally clear", meta: "Logistics · 2 hr ago" },
    ],
  },
  {
    id: "tech",
    label: "Technology",
    accent: "violet",
    kicker: "Silicon & software",
    featured: {
      title: "Open model runs on a single laptop GPU without the cloud",
      dek: "The three-billion-parameter release trades raw scale for on-device privacy and genuinely usable offline performance.",
      byline: "By Theo Almeida",
      read: "7 min read",
    },
    more: [
      { title: "Right-to-repair law adds phones and tablets in January", meta: "Policy · 1 hr ago" },
      { title: "Browsers move to kill the last of third-party cookies", meta: "Web · 4 hr ago" },
    ],
  },
  {
    id: "science",
    label: "Science",
    accent: "sky",
    kicker: "Discovery",
    featured: {
      title: "Deep-sea cameras catch a squid species never filmed alive",
      dek: "Footage from 2,400 meters shows the animal hunting far shallower than population models had predicted for the region.",
      byline: "By Lena Osei",
      read: "5 min read",
    },
    more: [
      { title: "Ancient riverbed on Mars hints at a longer wet era", meta: "Space · 3 hr ago" },
      { title: "Lab-grown enamel repairs cavities in early trials", meta: "Health · 6 hr ago" },
    ],
  },
  {
    id: "climate",
    label: "Climate",
    accent: "emerald",
    kicker: "A warming world",
    featured: {
      title: "Grid stores a full day of solar for the first time in the region",
      dek: "New iron-air batteries carried the entire evening peak without firing a single gas plant, operators confirmed on Tuesday.",
      byline: "By Idris Hassan",
      read: "6 min read",
    },
    more: [
      { title: "Reforested slopes cut landslide risk, ten-year study finds", meta: "Research · 2 hr ago" },
      { title: "Cities trade asphalt for reflective paint to beat the heat", meta: "Feature · 5 hr ago" },
    ],
  },
  {
    id: "health",
    label: "Health",
    accent: "rose",
    kicker: "Body & mind",
    featured: {
      title: "Blood test flags pancreatic cancer months before symptoms",
      dek: "The screening spots a cluster of protein markers with 88 percent accuracy across a four-thousand-patient validation study.",
      byline: "By Grace Okonkwo",
      read: "5 min read",
    },
    more: [
      { title: "Sleep researchers link late meals to next-day focus dips", meta: "Wellness · 1 hr ago" },
      { title: "Rural clinics expand telehealth after a funding boost", meta: "Access · 3 hr ago" },
    ],
  },
  {
    id: "markets",
    label: "Markets",
    accent: "amber",
    kicker: "Trading floor",
    featured: {
      title: "Bond yields ease as inflation prints below forecast",
      dek: "Two-year notes rallied hardest after the surprise slowdown in core prices reset bets on the next rate decision.",
      byline: "By Sofia Renner",
      read: "3 min read",
    },
    more: [
      { title: "Copper hits a two-year high on grid demand", meta: "Commodities · 30 min ago" },
      { title: "Small caps outrun the index for a third straight week", meta: "Equities · 2 hr ago" },
    ],
  },
  {
    id: "culture",
    label: "Culture",
    accent: "violet",
    kicker: "Arts & ideas",
    featured: {
      title: "A debut novel maps a city through its all-night diners",
      dek: "Critics are calling the 320-page portrait the year's most tender study of strangers who share a counter but never a name.",
      byline: "By Noah Bergstrom",
      read: "6 min read",
    },
    more: [
      { title: "Restored silent film screens with a live 40-piece score", meta: "Film · 4 hr ago" },
      { title: "Museum opens its archive vaults to the public", meta: "Art · 5 hr ago" },
    ],
  },
  {
    id: "sports",
    label: "Sports",
    accent: "sky",
    kicker: "The scoreboard",
    featured: {
      title: "Underdogs stun the champions with a stoppage-time winner",
      dek: "A nineteen-year-old substitute settled the semifinal in front of a sold-out stand and sent the tie to a decider abroad.",
      byline: "By Amara Diallo",
      read: "4 min read",
    },
    more: [
      { title: "Marathon record falls by 41 seconds in cool conditions", meta: "Athletics · 1 hr ago" },
      { title: "Rookie signs the richest first contract in league history", meta: "Transfers · 3 hr ago" },
    ],
  },
  {
    id: "politics",
    label: "Politics",
    accent: "rose",
    kicker: "Power & policy",
    featured: {
      title: "Housing bill clears committee with rare cross-party support",
      dek: "The measure ties zoning reform to transit funding in fast-growing suburbs, a trade that pulled skeptics from both benches.",
      byline: "By Elena Marchetti",
      read: "5 min read",
    },
    more: [
      { title: "Redistricting map heads to the state supreme court", meta: "Courts · 2 hr ago" },
      { title: "Turnout in early voting outpaces the last cycle", meta: "Elections · 4 hr ago" },
    ],
  },
  {
    id: "travel",
    label: "Travel",
    accent: "indigo",
    kicker: "Away",
    featured: {
      title: "Night trains return to three routes across the Alps",
      dek: "Sleeper cars are selling out for winter as flyers trade fluorescent airports for the quiet of a mountain dawn arrival.",
      byline: "By Kaito Mori",
      read: "4 min read",
    },
    more: [
      { title: "A quiet island bets on car-free tourism", meta: "Escapes · 3 hr ago" },
      { title: "Visa-free travel expands for a dozen more countries", meta: "Advisory · 6 hr ago" },
    ],
  },
];

export default function ScrollableTabs() {
  const reduce = useReducedMotion();
  const [activeId, setActiveId] = useState<string>(TABS[0].id);
  const [canLeft, setCanLeft] = useState(false);
  const [canRight, setCanRight] = useState(false);

  const scrollRef = useRef<HTMLDivElement | null>(null);
  const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  const active = useMemo(
    () => TABS.find((t) => t.id === activeId) ?? TABS[0],
    [activeId],
  );
  const accent = ACCENT[active.accent];

  const updateScroll = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    setCanLeft(el.scrollLeft > 4);
    setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
  }, []);

  useEffect(() => {
    updateScroll();
    const el = scrollRef.current;
    if (!el) return;
    const ro = new ResizeObserver(updateScroll);
    ro.observe(el);
    window.addEventListener("resize", updateScroll);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", updateScroll);
    };
  }, [updateScroll]);

  const selectAndFocus = useCallback(
    (id: string) => {
      setActiveId(id);
      const el = tabRefs.current[id];
      if (el) {
        el.focus();
        el.scrollIntoView({
          inline: "nearest",
          block: "nearest",
          behavior: reduce ? "auto" : "smooth",
        });
      }
    },
    [reduce],
  );

  const onTabKeyDown = useCallback(
    (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
      const last = TABS.length - 1;
      let next = -1;
      if (e.key === "ArrowRight") next = index === last ? 0 : index + 1;
      else if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1;
      else if (e.key === "Home") next = 0;
      else if (e.key === "End") next = last;
      if (next >= 0) {
        e.preventDefault();
        selectAndFocus(TABS[next].id);
      }
    },
    [selectAndFocus],
  );

  const nudge = useCallback(
    (dir: -1 | 1) => {
      const el = scrollRef.current;
      if (!el) return;
      el.scrollBy({
        left: dir * el.clientWidth * 0.72,
        behavior: reduce ? "auto" : "smooth",
      });
    },
    [reduce],
  );

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes tabscroll-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.7); }
        }
        .tabscroll-live { animation: tabscroll-pulse 1.8s ease-in-out infinite; }
        .tabscroll-bar { scrollbar-width: none; -ms-overflow-style: none; }
        .tabscroll-bar::-webkit-scrollbar { display: none; }
        @media (prefers-reduced-motion: reduce) {
          .tabscroll-live { animation: none; }
          .tabscroll-bar { scroll-behavior: auto; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-8 flex flex-col gap-3">
          <span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            <span className="tabscroll-live h-2 w-2 rounded-full bg-emerald-500" />
            Live newsroom
          </span>
          <h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            The Meridian Wire
          </h2>
          <p className="max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Swipe the rail or arrow through every desk. Twelve categories,
            updated as stories break — scroll sideways to find yours.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Tab rail */}
          <div className="flex items-stretch gap-1 border-b border-slate-200 px-1.5 dark:border-slate-800">
            <button
              type="button"
              onClick={() => nudge(-1)}
              disabled={!canLeft}
              aria-label="Scroll categories left"
              className="my-2 flex shrink-0 items-center justify-center rounded-lg px-1 text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-25 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
                <path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>

            <div className="relative min-w-0 flex-1">
              <div
                ref={scrollRef}
                onScroll={updateScroll}
                role="tablist"
                aria-label="News categories"
                aria-orientation="horizontal"
                className="tabscroll-bar flex gap-1 overflow-x-auto scroll-smooth py-2"
              >
                {TABS.map((tab, i) => {
                  const isActive = tab.id === activeId;
                  const ta = ACCENT[tab.accent];
                  const count = tab.more.length + 1;
                  return (
                    <button
                      key={tab.id}
                      ref={(node) => {
                        tabRefs.current[tab.id] = node;
                      }}
                      type="button"
                      role="tab"
                      id={`tabscroll-tab-${tab.id}`}
                      aria-selected={isActive}
                      aria-controls={`tabscroll-panel-${tab.id}`}
                      tabIndex={isActive ? 0 : -1}
                      onClick={() => selectAndFocus(tab.id)}
                      onKeyDown={(e) => onTabKeyDown(e, i)}
                      className={[
                        "group relative flex shrink-0 items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium outline-none transition-colors",
                        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                        isActive
                          ? "text-slate-900 dark:text-white"
                          : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
                      ].join(" ")}
                    >
                      <span
                        className={[
                          "h-1.5 w-1.5 rounded-full transition-opacity",
                          ta.dot,
                          isActive ? "opacity-100" : "opacity-50 group-hover:opacity-80",
                        ].join(" ")}
                      />
                      <span className="whitespace-nowrap">{tab.label}</span>
                      <span
                        className={[
                          "rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums transition-colors",
                          isActive
                            ? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
                            : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                        ].join(" ")}
                      >
                        {count}
                      </span>

                      {isActive &&
                        (reduce ? (
                          <span
                            aria-hidden="true"
                            className={`pointer-events-none absolute inset-x-3 -bottom-2 h-0.5 rounded-full ${ta.bar}`}
                          />
                        ) : (
                          <motion.span
                            aria-hidden="true"
                            layoutId="tabscroll-indicator"
                            className={`pointer-events-none absolute inset-x-3 -bottom-2 h-0.5 rounded-full ${ta.bar}`}
                            transition={{ type: "spring", stiffness: 520, damping: 40 }}
                          />
                        ))}
                    </button>
                  );
                })}
              </div>

              {/* Edge fades */}
              <div
                aria-hidden="true"
                className={`pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-200 dark:from-slate-900 ${canLeft ? "opacity-100" : "opacity-0"}`}
              />
              <div
                aria-hidden="true"
                className={`pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent transition-opacity duration-200 dark:from-slate-900 ${canRight ? "opacity-100" : "opacity-0"}`}
              />
            </div>

            <button
              type="button"
              onClick={() => nudge(1)}
              disabled={!canRight}
              aria-label="Scroll categories right"
              className="my-2 flex shrink-0 items-center justify-center rounded-lg px-1 text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-25 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
                <path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          </div>

          {/* Panel */}
          <div className="p-5 sm:p-7">
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={active.id}
                role="tabpanel"
                id={`tabscroll-panel-${active.id}`}
                aria-labelledby={`tabscroll-tab-${active.id}`}
                tabIndex={0}
                initial={reduce ? false : { opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={{ duration: reduce ? 0 : 0.24, ease: "easeOut" }}
                className="outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
              >
                <div className="flex items-center justify-between gap-4">
                  <p className={`text-xs font-semibold uppercase tracking-[0.18em] ${accent.kicker}`}>
                    {active.kicker}
                  </p>
                  <span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
                    {active.more.length + 1} stories
                  </span>
                </div>

                <article className={`mt-4 border-l-2 pl-4 ${accent.edge}`}>
                  <h3 className="text-lg font-semibold leading-snug text-slate-900 sm:text-xl dark:text-white">
                    {active.featured.title}
                  </h3>
                  <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                    {active.featured.dek}
                  </p>
                  <p className="mt-3 text-xs font-medium text-slate-500 dark:text-slate-500">
                    {active.featured.byline} · {active.featured.read}
                  </p>
                </article>

                <ul className="mt-5 divide-y divide-slate-100 border-t border-slate-100 dark:divide-slate-800 dark:border-slate-800">
                  {active.more.map((story) => (
                    <li key={story.title} className="flex items-start gap-3 py-3">
                      <span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${accent.dot}`} />
                      <div className="min-w-0">
                        <p className="text-sm font-medium text-slate-800 dark:text-slate-200">
                          {story.title}
                        </p>
                        <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-500">
                          {story.meta}
                        </p>
                      </div>
                    </li>
                  ))}
                </ul>
              </motion.div>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →