Files
vercel__chat/apps/docs/components/ui/sliding-tabs.tsx
christopherkindl 3ae34b3ed1 feat(docs): migrate the homepage to geistdocs 1.19 and the Geist design system (#762)
Third repo in the design sync, after `vercel/geistdocs#216`/`#218` and
`vercel/flags#457`. Upgrades the docs site to `@vercel/geistdocs@1.19.2`
and brings the homepage onto the Geist design system.

`apps/docs` is `private: true`, so no changeset.

## Dependency

`1.19.2` peers on `next: ^16.2.11` and the app pinned `16.2.6`, so
**next moves to `16.2.12`** alongside it — without that pnpm reports an
unmet peer. Installed via `pnpm add --save-exact` per AGENTS.md. All 20
geistdocs subpaths this app imports still exist in 1.19.2; no API
breakage.

The footer needed no work: 1.16 already shipped the prop-less
Vercel-directory `<Footer />`.

## Layout — `home-grid.css` is gone

Deleted `app/styles/home-grid.css` (368 lines) and its `global.css`
import, and rebuilt each section on `grid-cols-12` / `col-span-*`:

| section | before (CSS) | after |
|---|---|---|
| OSS stats | 2×2 → 4×1 @768 | `col-span-6 min-[768px]:col-span-3` |
| Features | 2-up + full-width 3rd → 3×1 @961 | `col-span-12
sm:col-span-6 lg:col-span-4` |
| Code | stacked → sidebar 1/3 + code 2/3 @961 | `lg:col-span-8` /
`lg:col-span-4`, pinned with `col-start` + `row-start` |
| Integrations | 1×5 → tall left + 2×2 @961 | `lg:col-span-4
lg:row-span-2` + four `lg:col-span-4` |

The code section needs explicit `col-start`/`row-start` because the
sidebar follows the code in the DOM but sits left of it from `lg`.

Other layout changes:

- **Single gutter at the page root** (`mx-auto w-full max-w-[1448px]
px-4 sm:px-6`); removed the per-section horizontal padding that
duplicated it, so every section's content lands on the navbar/footer
content edge.
- **Content widened 1114px → 1400px**, the navbar's content span (1448 −
2×24).
- **Bottom gap above the footer trimmed ~320px → ~176px** — layout
`pb-32` → `pb-16` and page `pb-24 sm:pb-36` → `pb-12 sm:pb-16`. Three
paddings were stacking.

The 768px stats breakpoint is preserved with `min-[768px]:` — there's no
Tailwind equivalent here (`md`=601, `lg`=961) and four KPI columns at
601px would be ~140px each.

## Design — ported from vercel.com/ai-sdk

Read off the flagged source in `front/apps/vercel-marketing/.../ai-sdk`,
not the live site.

- **Code showcase tabs** → the `SlidingTabs` primitive: pill labels with
an animated indicator, full keyboard nav (arrows/Home/End, roving
tabindex), and an invisible-bold label so the tab doesn't shift width
when it bolds. Four tabs per group with dot pagination for the rest,
tabs above the code block. Copied into `components/ui/sliding-tabs.tsx`
with `cn` rewired and the `no-scrollbar` utility inlined (geistdocs
doesn't define it).
- **"Scale with confidence"** → heading and paragraph on one
bottom-aligned row (cols 1–4 / 8–12), then four bordered cards
`col-span-12 md:col-span-6 lg:col-span-3`. Type mapped from their
primitives: `SectionHeading size="48"` → `text-heading-40
lg:text-heading-48`, `SectionParagraph size="18"` → `text-copy-16
lg:text-copy-18`.
- **Feature row** → icon + muted eyebrow over a prominent statement.
Note this **inverts the previous emphasis**: the heading is now the
small muted label and the description the larger line, matching the
reference. Icons come from geistdocs' own set so they match Geist's line
weight: `IconLinked`, `IconWorkflow`, `IconAcronymTs`.
- **Get-started install snippet** → the shared `CommandPrompt`, with its
buttons on one row from `lg`.
- Remaining headings converted to `text-heading-*`.
- Navbar logo drops `height={22}` to take `LogoChatSdk`'s new 18px
default (renders 106.9×22 → 87.4×18).

## Two fixes worth calling out

**`lib/utils.ts` — `cn` was silently dropping typography.** Geist's
`text-copy-*`/`text-heading-*` share the `text-` prefix with colour
utilities, so stock `tailwind-merge` classifies them as colours and
drops the size whenever both appear in one `cn()` call. geistdocs ships
a `cn` that registers them as `font-size` for exactly this reason but
doesn't export it, so the config is replicated here. This was a latent
bug across the app, not just the new code.

**`Analytics`/`SpeedInsights` moved out of the `"use client"` provider**
into the server layout. Both emit `<script>`, and scripts rendered
inside a client tree never execute — so analytics wasn't firing on
client navigations. React 19.2.7 (pulled in by this bump) now warns
about it; the bug predates it.

## Verification

- `pnpm --filter docs build` passes (270 pages), `tsc --noEmit` clean,
`biome check` clean.
- Rendered output spot-checked for the tab strip, dot pagination, card
classes, and feature icons.

**`pnpm validate` could not be run** — it needs Node ≥20.19 and this
machine is on v20.11.1 (`pnpm check` dies on `styleText` from
`node:util`). Biome, tsc and build were run directly instead, but the
knip and test legs are unrun and should be confirmed in CI.

Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com>
2026-07-31 23:53:17 +01:00

463 lines
11 KiB
TypeScript

"use client";
import { cn } from "@/lib/utils";
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type HTMLAttributes,
type JSX,
type KeyboardEvent,
type MutableRefObject,
type ReactNode,
} from "react";
export type SlidingTabsOrientation = "horizontal" | "vertical";
interface SlidingTabsContextValue {
baseId: string;
onValueChange: (value: string) => void;
orientation: SlidingTabsOrientation;
value: string;
}
interface SlidingTabsListContextValue {
listRef: MutableRefObject<HTMLDivElement | null>;
}
const SlidingTabsContext = createContext<SlidingTabsContextValue | null>(null);
const SlidingTabsListContext =
createContext<SlidingTabsListContextValue | null>(null);
export interface SlidingTabsLabelProps extends HTMLAttributes<HTMLSpanElement> {
active?: boolean;
activeBackground?: boolean;
children: ReactNode;
}
function SlidingTabsLabel({
active,
activeBackground,
children,
className,
...props
}: SlidingTabsLabelProps): JSX.Element {
return (
<span
className={cn(
"relative inline-flex items-center justify-center rounded-[6px] px-3.5 py-1.5 text-center text-copy-14 transition-colors hover:text-gray-1000",
active ? "text-gray-1000" : "text-gray-900",
active && activeBackground ? "bg-gray-200" : null,
className,
)}
{...props}
>
<span
aria-hidden="true"
className="invisible flex items-center justify-center gap-1 font-medium"
>
{children}
</span>
<span
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center gap-1",
{
"font-medium": active,
},
)}
>
{children}
</span>
</span>
);
}
function useSlidingTabsContext(componentName: string): SlidingTabsContextValue {
const context = useContext(SlidingTabsContext);
if (!context) {
throw new Error(`${componentName} must be used within SlidingTabs.Root.`);
}
return context;
}
function useSlidingTabsListContext(
componentName: string,
): SlidingTabsListContextValue {
const context = useContext(SlidingTabsListContext);
if (!context) {
throw new Error(`${componentName} must be used within SlidingTabs.List.`);
}
return context;
}
export interface SlidingTabsRootProps<
TValue extends string = string,
> extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
onValueChange: (value: TValue) => void;
orientation?: SlidingTabsOrientation;
value: TValue;
}
function SlidingTabsRoot<TValue extends string = string>({
children,
onValueChange,
orientation = "horizontal",
value,
...props
}: SlidingTabsRootProps<TValue>): JSX.Element {
const baseId = useId();
return (
<SlidingTabsContext.Provider
value={{
baseId,
onValueChange: (nextValue) => onValueChange(nextValue as TValue),
orientation,
value,
}}
>
<div data-orientation={orientation} {...props}>
{children}
</div>
</SlidingTabsContext.Provider>
);
}
export interface SlidingTabsListProps extends HTMLAttributes<HTMLDivElement> {
activateOnFocus?: boolean;
loopFocus?: boolean;
}
function getEnabledTabs(list: HTMLDivElement | null): HTMLButtonElement[] {
if (!list) return [];
return Array.from(
list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
);
}
function getNextIndex({
currentIndex,
direction,
loopFocus,
tabCount,
}: {
currentIndex: number;
direction: -1 | 1;
loopFocus: boolean;
tabCount: number;
}): number {
const nextIndex = currentIndex + direction;
if (loopFocus) {
return (nextIndex + tabCount) % tabCount;
}
return Math.min(Math.max(nextIndex, 0), tabCount - 1);
}
function SlidingTabsList({
activateOnFocus = true,
children,
className,
loopFocus = true,
onKeyDown,
...props
}: SlidingTabsListProps): JSX.Element {
const { onValueChange, orientation } =
useSlidingTabsContext("SlidingTabs.List");
const listRef = useRef<HTMLDivElement | null>(null);
function selectTab(tab: HTMLButtonElement): void {
const value = tab.dataset.value;
if (!value) return;
tab.focus();
if (activateOnFocus) {
onValueChange(value);
}
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const tabs = getEnabledTabs(listRef.current);
if (tabs.length === 0) return;
const currentIndex = Math.max(
0,
tabs.indexOf(document.activeElement as HTMLButtonElement),
);
if (event.key === "Home") {
event.preventDefault();
selectTab(tabs[0]);
return;
}
if (event.key === "End") {
event.preventDefault();
selectTab(tabs[tabs.length - 1]);
return;
}
const previousKeys =
orientation === "horizontal"
? ["ArrowLeft", "ArrowUp"]
: ["ArrowUp", "ArrowLeft"];
const nextKeys =
orientation === "horizontal"
? ["ArrowRight", "ArrowDown"]
: ["ArrowDown", "ArrowRight"];
if (previousKeys.includes(event.key)) {
event.preventDefault();
selectTab(
tabs[
getNextIndex({
currentIndex,
direction: -1,
loopFocus,
tabCount: tabs.length,
})
],
);
return;
}
if (nextKeys.includes(event.key)) {
event.preventDefault();
selectTab(
tabs[
getNextIndex({
currentIndex,
direction: 1,
loopFocus,
tabCount: tabs.length,
})
],
);
}
}
return (
<SlidingTabsListContext.Provider value={{ listRef }}>
<div
aria-orientation={orientation}
className={cn(
"relative inline-flex gap-1 p-1",
orientation === "horizontal"
? "w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] @lg:w-fit @lg:max-w-none [&::-webkit-scrollbar]:hidden"
: "w-full flex-col",
className,
)}
data-orientation={orientation}
onKeyDown={handleKeyDown}
ref={listRef}
role="tablist"
{...props}
>
{children}
</div>
</SlidingTabsListContext.Provider>
);
}
export interface SlidingTabsTabProps<
TValue extends string = string,
> extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
panelId?: string;
value: TValue;
}
function SlidingTabsTab<TValue extends string = string>({
children,
className,
disabled,
id,
onClick,
panelId,
type = "button",
value,
...props
}: SlidingTabsTabProps<TValue>): JSX.Element {
const {
baseId,
onValueChange,
orientation,
value: activeValue,
} = useSlidingTabsContext("SlidingTabs.Tab");
const tabId = id ?? `${baseId}-${value}`;
const isActive = activeValue === value;
return (
<button
aria-controls={panelId}
aria-selected={isActive}
className={cn(
"relative z-10 rounded-[6px] focus-visible:outline-none focus-visible:shadow-[var(--ds-focus-ring)]",
orientation === "horizontal"
? "min-w-max flex-none whitespace-nowrap"
: "w-full",
className,
)}
data-active={isActive ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-orientation={orientation}
data-value={value}
disabled={disabled}
id={tabId}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented && !disabled) {
onValueChange(value);
}
}}
role="tab"
tabIndex={isActive ? 0 : -1}
type={type}
{...props}
>
<SlidingTabsLabel
active={isActive}
className={cn(orientation === "vertical" ? "w-full" : null)}
>
{children}
</SlidingTabsLabel>
</button>
);
}
export type SlidingTabsIndicatorProps = HTMLAttributes<HTMLSpanElement>;
function SlidingTabsIndicator({
className,
style,
...props
}: SlidingTabsIndicatorProps): JSX.Element | null {
const { orientation, value } = useSlidingTabsContext("SlidingTabs.Indicator");
const { listRef } = useSlidingTabsListContext("SlidingTabs.Indicator");
const [indicator, setIndicator] = useState<{
height: number;
left: number;
top: number;
width: number;
} | null>(null);
const measure = useCallback(() => {
const activeTab = listRef.current?.querySelector<HTMLElement>(
'[role="tab"][data-active]',
);
if (!activeTab) return;
setIndicator({
height: activeTab.offsetHeight,
left: activeTab.offsetLeft,
top: activeTab.offsetTop,
width: activeTab.offsetWidth,
});
}, [listRef]);
useEffect(() => {
measure();
const tabList = listRef.current;
const activeTab = tabList?.querySelector<HTMLElement>(
'[role="tab"][data-active]',
);
const observer =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(measure)
: null;
if (tabList) observer?.observe(tabList);
if (activeTab) observer?.observe(activeTab);
window.addEventListener("resize", measure);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measure);
};
}, [listRef, measure, value]);
if (!indicator) return null;
const cssVariables = {
"--active-tab-height": `${indicator.height}px`,
"--active-tab-left": `${indicator.left}px`,
"--active-tab-top": `${indicator.top}px`,
"--active-tab-width": `${indicator.width}px`,
} as CSSProperties;
return (
<span
aria-hidden
className={cn(
"pointer-events-none absolute z-0 rounded-[6px] bg-gray-200 transition-[left,width,top,height] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none",
orientation === "horizontal" ? "top-1 bottom-1" : "left-1 right-1",
className,
)}
data-orientation={orientation}
style={{
...cssVariables,
...(orientation === "horizontal"
? { left: indicator.left, width: indicator.width }
: { height: indicator.height, top: indicator.top }),
...style,
}}
{...props}
/>
);
}
export interface SlidingTabsPanelProps<
TValue extends string = string,
> extends HTMLAttributes<HTMLDivElement> {
keepMounted?: boolean;
value: TValue;
}
function SlidingTabsPanel<TValue extends string = string>({
children,
keepMounted = false,
value,
...props
}: SlidingTabsPanelProps<TValue>): JSX.Element | null {
const {
baseId,
orientation,
value: activeValue,
} = useSlidingTabsContext("SlidingTabs.Panel");
const isActive = activeValue === value;
if (!keepMounted && !isActive) return null;
return (
<div
aria-labelledby={`${baseId}-${value}`}
data-hidden={isActive ? undefined : ""}
data-orientation={orientation}
hidden={!isActive}
role="tabpanel"
{...props}
>
{children}
</div>
);
}
export const SlidingTabs = {
Indicator: SlidingTabsIndicator,
List: SlidingTabsList,
Panel: SlidingTabsPanel,
Root: SlidingTabsRoot,
Tab: SlidingTabsTab,
Label: SlidingTabsLabel,
};