Skip to content
← Blocks

Feature Grid

Explore project conversations, handoffs, and briefs in a selectable feature section.

Open ↗

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-grid.json

Use it

Scroll horizontally for long lines
"use client";
import { FeatureGrid } from "@/components/jez-ui/blocks/feature-grid";

export default function Example() {
  return (
    <>
      <FeatureGrid />
    </>
  );
}

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 {
  FeatureGrid,
  FeatureGridHeader,
  FeatureGridTitle,
  FeatureGridDescription,
  FeatureGridLayout,
  FeatureGridList,
  FeatureGridItem,
  FeatureGridItemLabel,
  FeatureGridItemDescription,
  FeatureGridPreview,
  FeatureGridPanel,
} from "@/components/jez-ui/blocks/feature-grid";

export default function FeatureGridExample() {
  return (
    <FeatureGrid defaultValue={10}>
      <FeatureGridHeader>
        <FeatureGridTitle>Work at your own pace.</FeatureGridTitle>
        <FeatureGridDescription>
          Choose a stage to explore.
        </FeatureGridDescription>
      </FeatureGridHeader>
      <FeatureGridLayout>
        <FeatureGridPreview>
          <FeatureGridPanel value={10}>
            A space for early ideas.
          </FeatureGridPanel>
          <FeatureGridPanel value={20}>
            Share a finished project with your team.
          </FeatureGridPanel>
        </FeatureGridPreview>
        <FeatureGridList>
          <FeatureGridItem value={10}>
            <FeatureGridItemLabel>Explore</FeatureGridItemLabel>
            <FeatureGridItemDescription className="pl-0">
              Collect and compare possibilities.
            </FeatureGridItemDescription>
          </FeatureGridItem>
          <FeatureGridItem value={20}>
            <FeatureGridItemLabel>Publish</FeatureGridItemLabel>
            <FeatureGridItemDescription className="pl-0">
              Put a considered idea into the world.
            </FeatureGridItemDescription>
          </FeatureGridItem>
        </FeatureGridList>
      </FeatureGridLayout>
    </FeatureGrid>
  );
}
Scroll horizontally for long lines
import { FeatureGrid, FeatureGridContent, FeatureGridTitle, FeatureGridItemTitle, FeatureGridItem, FeatureGridHeader, FeatureGridDescription, FeatureGridLayout, FeatureGridList, FeatureGridPreview, FeatureGridPanel, FeatureGridItemLabel, FeatureGridItemDescription } from "@/components/jez-ui/blocks/feature-grid";
Read the composition guide →

Props & types

Scroll horizontally for long lines
export type FeatureGridOptions = {
  className?: string;
  features?: typeof defaultFeatures;
  renderPreview?: (
    feature: (typeof defaultFeatures)[number],
    index: number,
  ) => React.ReactNode;
  comments?: typeof FeatureGridDefaultComments;
  handoffs?: typeof FeatureGridDefaultHandoffs;
  releaseCriteria?: typeof FeatureGridDefaultReleaseCriteria;
  heading?: React.ReactNode;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
};

export type FeatureGridProps = Omit<
  React.ComponentProps<"section">,
  keyof FeatureGridOptions
> &
  FeatureGridOptions;

FeatureGridProps

Source

View 3 source files

registry/blocks/feature-grid.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import {
  ArrowUpRight,
  Check,
  FileText,
  MessageSquare,
  GitBranch,
} from "lucide-react";
import { useControllable } from "../ui/use-controllable";
import { cn } from "../ui/utils";
type FeatureGridState = {
  value: number;
  setValue: (value: number) => void;
  id: string;
};
const FeatureGridContext = React.createContext<FeatureGridState | null>(null);
function useFeatureGrid() {
  const context = React.useContext(FeatureGridContext);
  if (!context)
    throw new Error("FeatureGrid items and panels must be inside FeatureGrid.");
  return context;
}
const defaultFeatures = [
  {
    title: "Keep the decision with the work.",
    text: "Briefs, comments, and the latest decision belong beside the task. Pick up where the conversation left off.",
    icon: MessageSquare,
  },
  {
    title: "Give every task an owner.",
    text: "See who’s moving things forward, what’s waiting for review, and where someone needs a hand.",
    icon: GitBranch,
  },
  {
    title: "Leave a useful paper trail.",
    text: "A short project brief gives everyone the same starting point. Keep the scope and release criteria close.",
    icon: FileText,
  },
];
export type FeatureGridOptions = {
  className?: string;
  features?: typeof defaultFeatures;
  renderPreview?: (
    feature: (typeof defaultFeatures)[number],
    index: number,
  ) => React.ReactNode;
  comments?: typeof FeatureGridDefaultComments;
  handoffs?: typeof FeatureGridDefaultHandoffs;
  releaseCriteria?: typeof FeatureGridDefaultReleaseCriteria;
  heading?: React.ReactNode;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
};
export type FeatureGridProps = Omit<
  React.ComponentProps<"section">,
  keyof FeatureGridOptions
> &
  FeatureGridOptions;
const FeatureGridDefaultComments = [
  [
    "JL",
    "Jo",
    "Could we keep recent projects above the search results? That covers most switches.",
  ],
  [
    "SK",
    "Sam",
    "Yes. I’ll show the last five, with the current project pinned at the top.",
  ],
];
const FeatureGridDefaultHandoffs = [
  ["Design review", "Jo Lee", "Complete"],
  ["Implementation", "Sam Kim", "In progress"],
  ["Release notes", "Alex Morgan", "Up next"],
];
const FeatureGridDefaultReleaseCriteria = [
  "Recent projects are one click away",
  "Every action works from the keyboard",
  "Empty and loading states are reviewed",
];
export function FeatureGrid({
  value: suppliedValue,
  defaultValue = 0,
  onValueChange,
  features = defaultFeatures,
  renderPreview,
  comments = FeatureGridDefaultComments,
  handoffs = FeatureGridDefaultHandoffs,
  releaseCriteria = FeatureGridDefaultReleaseCriteria,
  heading = (
    <>
      The context is part
      <br />
      of the project.
    </>
  ),
  className,
  children,
  ...rootProps
}: FeatureGridProps) {
  const [active, setActive] = useControllable<number>(
    suppliedValue,
    defaultValue,
    onValueChange,
  );
  const id = React.useId();
  const activeIndex = Math.min(Math.max(active, 0), features.length - 1);
  return (
    <section
      {...rootProps}
      className={cn("border-y border-border py-12 md:py-20", className)}
    >
      <FeatureGridContext.Provider
        value={{
          value: children !== undefined ? active : activeIndex,
          setValue: setActive,
          id,
        }}
      >
        {children !== undefined ? (
          children
        ) : (
          <>
            <FeatureGridHeader>
              <FeatureGridTitle>{heading}</FeatureGridTitle>
              <FeatureGridDescription>
                Work doesn’t happen in a list alone. Give your team the
                decisions, ownership, and detail they need to finish it.
              </FeatureGridDescription>
            </FeatureGridHeader>
            <FeatureGridLayout>
              <FeatureGridList>
                {features.map((f, i) => (
                  <FeatureGridItem
                    key={f.title}
                    value={i}
                    className={cn(
                      activeIndex === i
                        ? "text-foreground"
                        : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    <span className="flex items-center gap-3">
                      <f.icon
                        size={17}
                        className={activeIndex === i ? "text-primary" : ""}
                      />
                      <FeatureGridItemLabel>{f.title}</FeatureGridItemLabel>
                      <ArrowUpRight size={15} />
                    </span>
                    <FeatureGridItemDescription>
                      {f.text}
                    </FeatureGridItemDescription>
                  </FeatureGridItem>
                ))}
              </FeatureGridList>
              <FeatureGridPreview>
                {!features.length ? null : renderPreview ? (
                  renderPreview(features[activeIndex], activeIndex)
                ) : features !== defaultFeatures ? (
                  <div>
                    <FeatureGridItemTitle>
                      {features[activeIndex].title}
                    </FeatureGridItemTitle>
                    <p>{features[activeIndex].text}</p>
                  </div>
                ) : activeIndex === 0 ? (
                  <div>
                    <div className="mb-6 flex items-center justify-between border-b border-border pb-4">
                      <span className="text-xs font-medium">
                        Project switcher
                      </span>
                      <span className="text-xs text-muted-foreground">
                        2 comments
                      </span>
                    </div>
                    {comments.map(([initials, name, text]) => (
                      <div key={name} className="mb-6 flex gap-3">
                        <span className="grid size-7 shrink-0 place-items-center rounded-full bg-background text-[10px]">
                          {initials}
                        </span>
                        <div>
                          <p className="text-xs font-medium">{name}</p>
                          <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
                            {text}
                          </p>
                        </div>
                      </div>
                    ))}
                    <div className="flex items-center gap-2 border-t border-border pt-4 text-xs text-primary">
                      <Check size={14} /> Decision recorded in the project brief
                    </div>
                  </div>
                ) : activeIndex === 1 ? (
                  <div>
                    <FeatureGridItemTitle>
                      A handoff without the meeting.
                    </FeatureGridItemTitle>
                    {handoffs.map(([step, owner, status], i) => (
                      <div
                        key={step}
                        className="flex items-center gap-4 border-t border-border py-5"
                      >
                        <span
                          className={cn(
                            "grid size-8 place-items-center rounded-full text-xs",
                            i === 1
                              ? "bg-primary text-primary-foreground"
                              : "bg-background",
                          )}
                        >
                          {i === 0 ? (
                            <Check size={14} />
                          ) : (
                            owner
                              .split(" ")
                              .map((n) => n[0])
                              .join("")
                          )}
                        </span>
                        <div className="flex-1">
                          <p className="text-sm font-medium">{step}</p>
                          <p className="mt-1 text-xs text-muted-foreground">
                            {owner}
                          </p>
                        </div>
                        <span className="text-[11px] text-muted-foreground">
                          {status}
                        </span>
                      </div>
                    ))}
                  </div>
                ) : (
                  <div>
                    <div className="flex items-center gap-2 text-xs text-muted-foreground">
                      <FileText size={15} /> Project brief · Updated by Alex
                    </div>
                    <h3 className="mb-5 mt-6 text-2xl">Workspace launch</h3>
                    <p className="text-sm leading-relaxed">
                      Make it easier to move between projects without losing
                      your place.
                    </p>
                    <h4 className="mb-3 mt-6 text-xs font-medium">
                      Ready to release when
                    </h4>
                    <ul className="grid gap-3 text-xs text-muted-foreground">
                      {releaseCriteria.map((t) => (
                        <li key={t} className="flex items-center gap-2">
                          <Check size={13} className="text-primary" />
                          {t}
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </FeatureGridPreview>
            </FeatureGridLayout>
          </>
        )}
      </FeatureGridContext.Provider>
    </section>
  );
}

export function FeatureGridContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="feature-grid-content"
      className={cn("mb-10 grid gap-4 md:grid-cols-2", className)}
      {...props}
    />
  );
}
export function FeatureGridTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="feature-grid-title"
      className={cn("max-w-md text-3xl leading-tight md:text-4xl", className)}
      {...props}
    />
  );
}
export function FeatureGridItemTitle({
  className,
  ...props
}: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="feature-grid-itemtitle"
      className={cn("mb-6 text-lg", className)}
      {...props}
    />
  );
}

export function FeatureGridItem({
  className,
  value,
  onClick,
  ...props
}: Omit<React.ComponentProps<"button">, "value"> & { value?: number }) {
  const context = React.useContext(FeatureGridContext);
  if (value !== undefined && !context)
    throw new Error("FeatureGridItem with a value must be inside FeatureGrid.");
  const selected = value !== undefined && context?.value === value;
  return (
    <button
      type="button"
      data-slot="feature-grid-item"
      id={value === undefined ? undefined : `${context?.id}-trigger-${value}`}
      aria-pressed={value === undefined ? undefined : selected}
      data-state={selected ? "active" : "inactive"}
      className={cn(
        "border-b border-border py-6 text-left first:pt-0 last:border-0",
        value !== undefined &&
          (selected
            ? "text-foreground"
            : "text-muted-foreground hover:text-foreground"),
        className,
      )}
      {...props}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented && value !== undefined)
          context?.setValue(value);
      }}
    />
  );
}
/** The original Content export represents the heading area. Prefer Header in new compositions. */
export function FeatureGridHeader(
  props: React.ComponentProps<typeof FeatureGridContent>,
) {
  return <FeatureGridContent data-slot="feature-grid-header" {...props} />;
}
export function FeatureGridDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="feature-grid-description"
      className={cn(
        "max-w-sm text-sm leading-relaxed text-muted-foreground md:justify-self-end",
        className,
      )}
      {...props}
    />
  );
}
export function FeatureGridLayout({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="feature-grid-layout"
      className={cn(
        "grid gap-8 md:grid-cols-[.85fr_1.15fr] md:gap-16",
        className,
      )}
      {...props}
    />
  );
}
export function FeatureGridList({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="feature-grid-list"
      className={cn("grid content-start", className)}
      {...props}
    />
  );
}
export function FeatureGridPreview({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="feature-grid-preview"
      aria-live="polite"
      className={cn(
        "flex min-h-[330px] flex-col justify-center rounded-xl bg-muted/60 p-6 md:p-9",
        className,
      )}
      {...props}
    />
  );
}
export function FeatureGridPanel({
  value,
  ...props
}: React.ComponentProps<"div"> & { value: number }) {
  const context = useFeatureGrid();
  if (context.value !== value) return null;
  return (
    <div
      role="region"
      aria-labelledby={`${context.id}-trigger-${value}`}
      data-slot="feature-grid-panel"
      {...props}
    />
  );
}
export function FeatureGridItemLabel({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="feature-grid-item-label"
      className={cn("flex-1 text-base font-medium", className)}
      {...props}
    />
  );
}
export function FeatureGridItemDescription({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="feature-grid-item-description"
      className={cn(
        "mt-3 block max-w-md pl-[29px] text-sm leading-relaxed text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}

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 →