Skip to main content
Glama
install-tabs.tsx13.7 kB
"use client"; import * as React from "react"; import { Prose } from "../ui/prose"; import { cn } from "@/client/lib/utils"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { GeminiIcon } from "../ui/icons/gemini"; import { ZedIcon } from "../ui/icons/zed"; import { WarpIcon } from "../ui/icons/warp"; import { VSCodeIcon } from "../ui/icons/vscode"; import { WindsurfIcon } from "../ui/icons/windsurf"; import { CodexIcon } from "../ui/icons/codex"; import { ClaudeIcon } from "../ui/icons/claude"; import { CursorIcon } from "../ui/icons/cursor"; export type TabProps = { id: string; title: string; children: React.ReactNode; }; export function Tab(_props: TabProps) { return null; } export default function InstallTabs({ children, initialIndex = 0, current, onChange, className = "", }: { children: React.ReactNode; initialIndex?: number; current?: number; onChange?: (next: number) => void; className?: string; }) { const items = React.Children.toArray(children).filter( React.isValidElement, ) as React.ReactElement<TabProps>[]; const [internal, setInternal] = React.useState(initialIndex); const active = typeof current === "number" ? current : internal; const setActive = React.useCallback( (next: number) => { if (next < 0 || next >= items.length) return; if (typeof current === "number") onChange?.(next); else { setInternal(next); onChange?.(next); } }, [current, items.length, onChange], ); const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { if (!items.length) return; if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setActive((active + 1) % items.length); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setActive((active - 1 + items.length) % items.length); } else if (e.key === "Home") { e.preventDefault(); setActive(0); } else if (e.key === "End") { e.preventDefault(); setActive(items.length - 1); } }; const containerRef = React.useRef<HTMLDivElement | null>(null); const panelRefs = React.useRef<(HTMLDivElement | null)[]>([]); React.useLayoutEffect(() => { const c = containerRef.current; const p = panelRefs.current[active]; if (c && p) c.style.height = `${p.offsetHeight}px`; }, [active]); React.useEffect(() => { const c = containerRef.current; const next = panelRefs.current[active]; if (!c || !next) return; const from = c.offsetHeight; const to = next.offsetHeight; c.style.height = `${from}px`; c.offsetHeight; c.style.transition = "height 300ms cubic-bezier(0.2, 0.8, 0.2, 1)"; c.style.height = `${to}px`; const done = () => { c.style.transition = ""; }; c.addEventListener("transitionend", done, { once: true }); }, [active]); React.useEffect(() => { const c = containerRef.current; const p = panelRefs.current[active]; if (!c || !p) return; const ro = new ResizeObserver(() => { c.style.height = `${p.offsetHeight}px`; }); ro.observe(p); return () => ro.disconnect(); }, [active]); const navRef = React.useRef<HTMLDivElement | null>(null); const [scrollLeft, setScrollLeft] = React.useState(0); const startAutoScrollRight = React.useCallback(() => { const el = navRef.current; if (!el) return; const max = el.scrollWidth - el.clientWidth; if (max <= 0) return; // if already at (or very near) the end, do nothing if (el.scrollLeft >= max - 1) return; el.scrollTo({ left: max, behavior: "smooth" }); }, []); const startAutoScrollLeft = React.useCallback(() => { const el = navRef.current; if (!el) return; const max = el.scrollWidth - el.clientWidth; if (max <= 0) return; // if already at (or very near) the start, do nothing if (el.scrollLeft <= 1) return; el.scrollTo({ left: 0, behavior: "smooth" }); }, []); return ( <div className={cn("relative bg-background-2 rounded-2xl", className)}> <div className="flex" role="tablist" aria-orientation="horizontal" onKeyDown={onKeyDown} > {scrollLeft > 0 && ( <> {/* corner crop to hide tabs scrolling to the left */} <div className="bg-gradient-to-r from-background to-transparent from-25% w-12 h-6 absolute -top-6 left-0 z-5" /> <div className="bg-gradient-to-br from-background to-transparent from-50% size-4 absolute top-0 left-0 z-5" /> </> )} {/* only show once tabs have been scrolled to the right */} <div className={`absolute top-0 left-0 h-14 w-20 bg-gradient-to-r from-background-2/95 from-15% to-transparent rounded-tl-xl z-10 group/scrlL ${scrollLeft > 0 ? "" : "opacity-0 pointer-events-none"}`} onMouseEnter={startAutoScrollLeft} > <ChevronLeft className={`absolute top-1/2 -translate-y-1/2 left-1 size-5 group-hover/scrlL:scale-y-75 group-hover/scrlL:scale-x-125 group-hover/scrlL:-translate-x-0.5 duration-300 ease-[cubic-bezier(0.175,0.885,0.32,1.275)] ${scrollLeft > 0 ? "" : "translate-x-1/2"}`} /> </div> {/* [mask:radial-gradient(circle_at_var(--r)_var(--t),blue_var(--r),transparent_var(--r)),radial-gradient(circle_at_calc(100%-var(--r))_var(--t),green_var(--r),transparent_var(--r)),radial-gradient(circle_at_var(--2r)_var(--r),blue_var(--r),transparent_var(--r)),radial-gradient(circle_at_calc(100%-var(--2r))_var(--r),green_var(--r),transparent_var(--r)),linear-gradient(to_right,transparent,transparent_var(--r),red_var(--r),red_calc(100%-var(--r)),transparent_calc(100%-var(--r))),linear-gradient(to_bottom,transparent,transparent_var(--t),red_var(--t))] */} <div ref={navRef} // flex has a min-width-contents by default causing a blowout of container width in getting-started.tsx, or wherever used, so w-0 is used to fix that, and flex-1 makes it fill the space like w-full would className="flex min-w-0 w-0 flex-1 overflow-x-auto hide-scrollbar overflow-y-visible pt-8 pb-4 -mb-4 -mt-8 relative [--r:1rem] [--2r:2rem] [--t:3rem] [mask:radial-gradient(circle_at_calc(100%-var(--r))_var(--t),green_var(--r),transparent_var(--r)),radial-gradient(circle_at_var(--2r)_var(--r),blue_var(--r),transparent_var(--r)),radial-gradient(circle_at_calc(100%-var(--2r))_var(--r),green_var(--r),transparent_var(--r)),linear-gradient(to_right,red_calc(100%-var(--r)),transparent_calc(100%-var(--r))),linear-gradient(to_bottom,transparent,transparent_var(--t),red_var(--t))] pr-20" onScroll={(e) => setScrollLeft((e.target as HTMLDivElement).scrollLeft) } > {items.map((el, i) => { const { id, title } = el.props; const selected = i === active; const lastIdx = items.length - 1; const tabId = `${id}-tab`; const panelId = `${id}-panel`; return ( <button type="button" key={id} id={tabId} role="tab" aria-selected={selected} aria-controls={panelId} tabIndex={selected ? -1 : 0} onClick={() => setActive(i)} className="relative group/tab cursor-pointer focus-visible:ring-[3px] focus-visible:ring-violet-300 outline-none focus-visible:z-30 rounded-2xl focus-visible:ring-offset-2 focus-visible:ring-offset-background-2 duration-300" > {i > 0 && !selected && ( <> <div className="group-hover/tab:scale-100 group-active/tab:duration-75 group-active/tab:scale-0 group-hover/tab:duration-200 duration-0 scale-0 absolute left-1 -translate-x-full -top-2 size-3 bg-background-2 origin-bottom-right" /> <div className="group-hover/tab:scale-100 group-active/tab:duration-75 group-active/tab:scale-0 group-hover/tab:duration-200 duration-0 scale-0 absolute left-0 -translate-x-full -top-0 -translate-y-full size-4 rounded-full bg-background origin-bottom-right" /> </> )} <div className="absolute inset-[0.5px] bottom-3 bg-orange-300 bg-[repeating-linear-gradient(-45deg,#f97316,#f97316_12px,#f9731600_12px,#f9731600_24px)] rounded-xl z-0 group-active/tab:duration-75 group-active/tab:opacity-0 duration-300 group-active/tab:translate-y-2 group-active/tab:scale-90" /> <div className={`absolute inset-[0.5px] bottom-1 duration-300 bg-pink-600 bg-grid [--size:10px] rounded-xl z-0 group-active/tab:duration-75 group-active/tab:opacity-50 group-active/tab:scale-x-95 group-active/tab:translate-y-0.5 group-active/tab:scale-y-115 group-active/tab:bg-violet-600 group-active/tab:ease-[cubic-bezier(0.175,0.885,0.32,1.275)] ${ selected ? "" : "group-hover/tab:-translate-y-4 group-hover/tab:duration-400 group-hover/tab:ease-[cubic-bezier(0.175,0.885,0.32,1.275)]" }`} /> <div className={`bg-background-2 min-h-14 relative rounded-xl py-4 pr-5 pl-6 duration-300 perspective-distant text-nowrap flex items-center overflow-hidden gap-2 group-active/tab:duration-100 group-active/tab:rotate-x-5 group-active/tab:translate-y-1.5 group-active/tab:text-violet-500 z-10 ${ selected ? "text-violet-300 underline" : "group-hover/tab:text-violet-300 group-hover/tab:underline group-hover/tab:-rotate-x-45 group-hover/tab:-translate-y-6.5 group-hover/tab:ease-[cubic-bezier(0.175,0.885,0.32,1.275)] group-active/tab:scale-[0.95]" }`} > {/* unfinished: soft glow effect */} {/* <div className="absolute top-0 left-1/2 -translate-1/2 w-20 h-6 duration-300 group-hover/tab:bg-violet-400/50 rounded-[100%] blur-lg -z-10 pointer-events-none" /> */} {iconsByID[id] && ( <span className="size-4 -ml-2 grid place-items-center" aria-hidden="true" > {iconsByID[id]} </span> )} {/* desktop - show title by default */} <span className="truncate hidden md:block">{title}</span> {/* mobile - hide title by default, show when selected */} <span className="md:hidden contents"> {selected && title} </span> </div> {i < lastIdx && !selected && ( <> <div className="group-hover/tab:scale-100 group-active/tab:duration-75 group-active/tab:scale-0 group-hover/tab:duration-200 duration-0 scale-0 absolute right-1 translate-x-full -top-2 size-3 bg-background-2 origin-bottom-left" /> <div className="group-hover/tab:scale-100 group-active/tab:duration-75 group-active/tab:scale-0 group-hover/tab:duration-200 duration-0 scale-0 absolute right-0 translate-x-full -top-0 -translate-y-full size-4 rounded-full bg-background origin-bottom-left" /> </> )} </button> ); })} </div> <div className="absolute top-0 right-0 h-14 w-20 bg-gradient-to-l from-background-2 to-transparent rounded-tr-2xl z-10 group/srclR" onMouseEnter={startAutoScrollRight} > <ChevronRight className="absolute top-1/2 -translate-y-1/2 right-1 size-5 group-hover/srclR:scale-y-75 group-hover/srclR:scale-x-125 group-hover/srclR:translate-x-0.5 duration-300 ease-[cubic-bezier(0.175,0.885,0.32,1.275)]" /> </div> </div> <div ref={containerRef} className="relative transition-[height] delay-500 duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)] overflow-hidden" > {items.map((el, i) => { const { id, children: panelChildren } = el.props; const panelId = `${id}-panel`; const tabId = `${id}-tab`; const selected = i === active; const pos = i === active ? "current" : i < active ? "left" : "right"; return ( <div key={id} ref={(node) => { panelRefs.current[i] = node; }} id={panelId} role="tabpanel" aria-labelledby={tabId} aria-hidden={!selected} data-pos={pos} data-idx={i} data-current={active} className={`duration-400 ${ selected ? "visible opacity-100 blur-none" : `invisible opacity-0 blur-xl motion-safe:scale-x-110 motion-safe:scale-y-90 absolute top-0 ease-[cubic-bezier(0.175,0.885,0.32,1.275)] ${ i < active ? "motion-safe:-translate-x-1/4" : "motion-safe:translate-x-1/4" }` }`} > <Prose className="p-4 pt-0">{panelChildren}</Prose> </div> ); })} </div> </div> ); } const iconsByID: Record<string, React.ReactNode> = { cursor: <CursorIcon />, "claude-code": <ClaudeIcon />, "codex-cli": <CodexIcon />, windsurf: <WindsurfIcon />, vscode: <VSCodeIcon />, warp: <WarpIcon />, zed: <ZedIcon />, gemini: <GeminiIcon className="size-4" />, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/getsentry/sentry-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server