Feature Tabs
A tabbed feature story with connected controls and product panels.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/feature-tabs.jsonUse it
Scroll horizontally for long lines
"use client";
import { FeatureTabs } from "@/components/jez-ui/blocks/feature-tabs";
export default function Example() {
return (
<>
<FeatureTabs />
</>
);
}
Compose your own
These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.
Scroll horizontally for long lines
"use client";
import {
FeatureTabs,
FeatureTabsHeader,
FeatureTabsList,
FeatureTabsPanels,
} from "@/components/jez-ui/blocks/feature-tabs";
export default function Example() {
return (
<FeatureTabs>
<FeatureTabsHeader />
<FeatureTabsList />
<FeatureTabsPanels />
</FeatureTabs>
);
}
Scroll horizontally for long lines
import { FeatureTabs, FeatureTabsDefaultItems, FeatureTabsTitle, FeatureTabsDescription, FeatureTabsListRoot, FeatureTabsTrigger, FeatureTabsPanel, FeatureTabsHeader, FeatureTabsList, FeatureTabsPanels } from "@/components/jez-ui/blocks/feature-tabs";Props & types
Scroll horizontally for long lines
export type FeatureTabData = {
value: string;
label: React.ReactNode;
title: React.ReactNode;
description: React.ReactNode;
outcomes: React.ReactNode[];
preview?: React.ReactNode;
};
export type FeatureTabsProps = Omit<
React.ComponentProps<"section">,
"value" | "defaultValue" | "onValueChange"
> & {
items?: FeatureTabData[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
FeatureTabsPropsSource
View 3 source files
registry/blocks/feature-tabs.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { useControllable } from "../ui/use-controllable";
import { cn } from "../ui/utils";
export type FeatureTabData = {
value: string;
label: React.ReactNode;
title: React.ReactNode;
description: React.ReactNode;
outcomes: React.ReactNode[];
preview?: React.ReactNode;
};
export const FeatureTabsDefaultItems: FeatureTabData[] = [
{
value: "plan",
label: "Plan",
title: "Make the next move obvious.",
description:
"Turn an unformed request into a brief that has a decision, owner, and release boundary.",
outcomes: [
"Context stays with the work",
"Everyone sees the current decision",
"Scope has a visible home",
],
},
{
value: "build",
label: "Build",
title: "Keep the work connected as it changes.",
description:
"Shape feedback where the task lives and preserve the useful reason behind each adjustment.",
outcomes: [
"Useful feedback stays actionable",
"Handoffs carry their history",
"Progress is easy to scan",
],
},
{
value: "ship",
label: "Ship",
title: "Release with a record people can trust.",
description:
"Turn the final review into a concise handoff that answers what changed and what happens next.",
outcomes: [
"Owners are clear",
"Release notes write themselves",
"Nothing important gets lost",
],
},
];
type State = {
items: FeatureTabData[];
value: string;
setValue: (value: string) => void;
id: string;
};
const Context = React.createContext<State | null>(null);
function useFeatureTabs() {
const context = React.useContext(Context);
if (!context)
throw new Error("FeatureTabs parts must be inside FeatureTabs.");
return context;
}
export type FeatureTabsProps = Omit<
React.ComponentProps<"section">,
"value" | "defaultValue" | "onValueChange"
> & {
items?: FeatureTabData[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
export function FeatureTabs({
items = FeatureTabsDefaultItems,
value,
defaultValue,
onValueChange,
children,
className,
...props
}: FeatureTabsProps) {
const first = items[0]?.value ?? "";
const [selected, setSelected] = useControllable(
value,
defaultValue ?? first,
onValueChange,
);
const active = items.some((item) => item.value === selected)
? selected
: first;
return (
<Context.Provider
value={{ items, value: active, setValue: setSelected, id: React.useId() }}
>
<section {...props} className={cn("py-12 md:py-20", className)}>
{children !== undefined ? (
children
) : (
<>
<FeatureTabsHeader />
<FeatureTabsList />
<FeatureTabsPanels />
</>
)}
</section>
</Context.Provider>
);
}
export const FeatureTabsTitle = React.forwardRef<
HTMLHeadingElement,
React.ComponentProps<"h2">
>(function FeatureTabsTitle({ className, ...props }, ref) {
return (
<h2
ref={ref}
data-slot="feature-tabs-title"
className={cn(
"max-w-2xl text-4xl leading-[1.02] tracking-[-0.04em] md:text-5xl",
className,
)}
{...props}
/>
);
});
export const FeatureTabsDescription = React.forwardRef<
HTMLParagraphElement,
React.ComponentProps<"p">
>(function FeatureTabsDescription({ className, ...props }, ref) {
return (
<p
ref={ref}
data-slot="feature-tabs-description"
className={cn(
"mt-5 max-w-xl text-sm leading-relaxed text-muted-foreground",
className,
)}
{...props}
/>
);
});
export const FeatureTabsListRoot = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(function FeatureTabsListRoot({ className, ...props }, ref) {
return (
<div
ref={ref}
data-slot="feature-tabs-list"
role="tablist"
aria-label="Product capabilities"
className={cn(
"mt-10 flex overflow-x-auto border-b border-border",
className,
)}
{...props}
/>
);
});
export const FeatureTabsTrigger = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { value: string }
>(function FeatureTabsTrigger(
{ value, className, onClick, onKeyDown, children, ...props },
ref,
) {
const { items, value: selected, setValue, id } = useFeatureTabs();
const index = items.findIndex((item) => item.value === value);
const selectAndFocus = (nextValue: string) => {
setValue(nextValue);
document.getElementById(`${id}-${nextValue}-tab`)?.focus();
};
const move = (delta: number) => {
const next = items[(index + delta + items.length) % items.length];
if (next) selectAndFocus(next.value);
};
return (
<button
ref={ref}
id={`${id}-${value}-tab`}
type="button"
role="tab"
aria-selected={selected === value}
aria-controls={`${id}-${value}-panel`}
tabIndex={selected === value ? 0 : -1}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) setValue(value);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowRight") {
event.preventDefault();
move(1);
}
if (event.key === "ArrowLeft") {
event.preventDefault();
move(-1);
}
if (event.key === "Home") {
event.preventDefault();
selectAndFocus(items[0]?.value ?? value);
}
if (event.key === "End") {
event.preventDefault();
selectAndFocus(items[items.length - 1]?.value ?? value);
}
}}
data-slot="feature-tabs-trigger"
className={cn(
"shrink-0 border-b-2 border-transparent px-5 py-4 text-sm font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary data-[state=active]:border-primary data-[state=active]:text-primary",
className,
)}
data-state={selected === value ? "active" : "inactive"}
{...props}
>
{children}
</button>
);
});
export const FeatureTabsPanel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { value: string }
>(function FeatureTabsPanel({ value, className, ...props }, ref) {
const { value: selected, id } = useFeatureTabs();
return (
<div
ref={ref}
id={`${id}-${value}-panel`}
role="tabpanel"
aria-labelledby={`${id}-${value}-tab`}
hidden={selected !== value}
data-slot="feature-tabs-panel"
className={cn("pt-10", className)}
{...props}
/>
);
});
export function FeatureTabsHeader({
children,
...props
}: React.ComponentProps<"header">) {
return (
<header
{...props}
className={cn(
"grid gap-5 md:grid-cols-[1.2fr_.8fr] md:items-end",
props.className,
)}
>
{children !== undefined ? (
children
) : (
<>
<FeatureTabsTitle>
Three moments. One clear way to move work forward.
</FeatureTabsTitle>
<FeatureTabsDescription>
Choose a stage to inspect the work it makes easier for a team to do.
</FeatureTabsDescription>
</>
)}
</header>
);
}
export function FeatureTabsList({
children,
...props
}: Partial<React.ComponentProps<typeof FeatureTabsListRoot>> & {
children?: React.ReactNode;
}) {
const { items } = useFeatureTabs();
return (
<FeatureTabsListRoot {...props}>
{children !== undefined
? children
: items.map((item) => (
<FeatureTabsTrigger key={item.value} value={item.value}>
{item.label}
</FeatureTabsTrigger>
))}
</FeatureTabsListRoot>
);
}
export function FeatureTabsPanels({
children,
...props
}: React.ComponentProps<"div">) {
const { items } = useFeatureTabs();
return (
<div {...props} data-slot="feature-tabs-panels">
{children !== undefined
? children
: items.map((item) => (
<FeatureTabsPanel key={item.value} value={item.value}>
<div className="grid gap-10 lg:grid-cols-[.85fr_1.15fr] lg:items-center">
<div>
<h3 className="max-w-md text-3xl leading-[1.05] tracking-[-0.035em] md:text-4xl">
{item.title}
</h3>
<p className="mt-5 max-w-md text-sm leading-relaxed text-muted-foreground">
{item.description}
</p>
<ul className="mt-7 space-y-3 text-sm">
{item.outcomes.map((outcome, index) => (
<li key={index} className="flex gap-3">
<Check
size={17}
className="mt-0.5 shrink-0 text-primary"
/>
{outcome}
</li>
))}
</ul>
</div>
<div className="min-h-72 rounded-xl border border-border bg-muted p-5 md:min-h-96 md:p-7">
<div className="flex justify-between border-b border-border pb-4 text-xs">
<span>Project overview</span>
<span className="text-muted-foreground">Updated now</span>
</div>
<div className="mt-7 rounded-lg bg-background p-5 shadow-[0_14px_32px_rgb(0_0_0_/_0.08)]">
<p className="text-xs text-muted-foreground">
Current decision
</p>
<p className="mt-3 text-xl tracking-[-0.025em]">
{item.title}
</p>
<div className="mt-7 border-t border-border pt-4 text-xs text-muted-foreground">
{item.outcomes[0]}
</div>
</div>
{item.preview}
</div>
</div>
</FeatureTabsPanel>
))}
</div>
);
}
registry/ui/use-controllable.ts
Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
value: T | undefined,
initial: T,
onChange?: (value: T) => void,
) {
const [local, setLocal] = useState(initial);
const current = useRef(value === undefined ? local : value);
current.current = value === undefined ? local : value;
return [
current.current,
(action: SetStateAction<T>) => {
const next =
typeof action === "function"
? (action as (previous: T) => T)(current.current)
: action;
current.current = next;
if (value === undefined) setLocal(next);
onChange?.(next);
},
] as const;
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
Dependencies
lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →