Skip to content
Blocks

Feature Carousel

A keyboard-controlled feature carousel with focused product previews.

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

Use it

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

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

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 {
  FeatureCarousel,
  FeatureCarouselHeading,
  FeatureCarouselSlides,
  FeatureCarouselControls,
} from "@/components/jez-ui/blocks/feature-carousel";
export default function Example() {
  return (
    <FeatureCarousel layout="rail">
      <FeatureCarouselHeading />
      <FeatureCarouselSlides />
      <FeatureCarouselControls />
    </FeatureCarousel>
  );
}
Scroll horizontally for long lines
import { FeatureCarousel, FeatureCarouselDefaultItems, FeatureCarouselHeader, FeatureCarouselTitle, FeatureCarouselDescription, FeatureCarouselContent, FeatureCarouselSlide, FeatureCarouselHeading, FeatureCarouselSlides, FeatureCarouselControls } from "@/components/jez-ui/blocks/feature-carousel";
Read the composition guide →

Props & types

Scroll horizontally for long lines
export type FeatureCarouselItemData = {
  value: string;
  label: React.ReactNode;
  title: React.ReactNode;
  description: React.ReactNode;
  detail?: React.ReactNode;
  preview?: React.ReactNode;
  href?: string;
  actionLabel?: React.ReactNode;
  onAction?: () => void;
};

export type FeatureCarouselProps = Omit<
  React.ComponentProps<"section">,
  "value" | "defaultValue" | "onValueChange"
> & {
  items?: FeatureCarouselItemData[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  layout?: "spotlight" | "rail";
};

FeatureCarouselProps

Source

View 3 source files

registry/blocks/feature-carousel.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react";
import { useControllable } from "../ui/use-controllable";
import { cn } from "../ui/utils";

export type FeatureCarouselItemData = {
  value: string;
  label: React.ReactNode;
  title: React.ReactNode;
  description: React.ReactNode;
  detail?: React.ReactNode;
  preview?: React.ReactNode;
  href?: string;
  actionLabel?: React.ReactNode;
  onAction?: () => void;
};

export const FeatureCarouselDefaultItems: FeatureCarouselItemData[] = [
  {
    value: "brief",
    label: "Shared context",
    title: "The brief stays beside the work.",
    description:
      "Give every decision a home, so people arrive with the context they need instead of a trail of links.",
    detail: "Briefs · decisions · release criteria",
  },
  {
    value: "review",
    label: "Clear review",
    title: "Review work while it still has momentum.",
    description:
      "Collect a useful response in the place the work happened, then turn it into a visible next step.",
    detail: "Comments · requests · approvals",
  },
  {
    value: "release",
    label: "Confident release",
    title: "Know what changed before you ship.",
    description:
      "A compact handoff connects the final decision, owner, and release note without another status meeting.",
    detail: "Owners · handoffs · launch notes",
  },
];

type FeatureCarouselState = {
  items: FeatureCarouselItemData[];
  active: string;
  setActive: (value: string) => void;
  layout: "spotlight" | "rail";
  id: string;
};
const Context = React.createContext<FeatureCarouselState | null>(null);
function useFeatureCarousel() {
  const context = React.useContext(Context);
  if (!context)
    throw new Error("FeatureCarousel parts must be inside FeatureCarousel.");
  return context;
}

export type FeatureCarouselProps = Omit<
  React.ComponentProps<"section">,
  "value" | "defaultValue" | "onValueChange"
> & {
  items?: FeatureCarouselItemData[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  layout?: "spotlight" | "rail";
};
export function FeatureCarousel({
  items = FeatureCarouselDefaultItems,
  value,
  defaultValue,
  onValueChange,
  layout = "spotlight",
  children,
  className,
  ...props
}: FeatureCarouselProps) {
  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,
        active,
        setActive: setSelected,
        layout,
        id: React.useId(),
      }}
    >
      <section
        {...props}
        aria-roledescription={props["aria-roledescription"] ?? "carousel"}
        className={cn(
          "overflow-hidden rounded-[14px] border border-border bg-background",
          className,
        )}
      >
        {children !== undefined ? (
          children
        ) : (
          <>
            <FeatureCarouselHeader />
            <FeatureCarouselSlides />
            {layout === "spotlight" && <FeatureCarouselControls />}
          </>
        )}
      </section>
    </Context.Provider>
  );
}

export const FeatureCarouselHeader = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<"div">
>(function FeatureCarouselHeader({ className, ...props }, ref) {
  return (
    <div
      ref={ref}
      data-slot="feature-carousel-header"
      className={cn(
        "flex items-center justify-between gap-5 border-b border-border px-6 py-5",
        className,
      )}
      {...props}
    />
  );
});
export const FeatureCarouselTitle = React.forwardRef<
  HTMLHeadingElement,
  React.ComponentProps<"h2">
>(function FeatureCarouselTitle({ className, ...props }, ref) {
  return (
    <h2
      ref={ref}
      data-slot="feature-carousel-title"
      className={cn(
        "max-w-xl text-3xl tracking-[-0.035em] md:text-4xl",
        className,
      )}
      {...props}
    />
  );
});
export const FeatureCarouselDescription = React.forwardRef<
  HTMLParagraphElement,
  React.ComponentProps<"p">
>(function FeatureCarouselDescription({ className, ...props }, ref) {
  return (
    <p
      ref={ref}
      data-slot="feature-carousel-description"
      className={cn(
        "mt-4 max-w-2xl text-sm leading-relaxed text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
});
export const FeatureCarouselContent = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<"div">
>(function FeatureCarouselContent({ className, ...props }, ref) {
  return (
    <div
      ref={ref}
      data-slot="feature-carousel-content"
      className={cn("overflow-hidden", className)}
      {...props}
    />
  );
});
export const FeatureCarouselSlide = React.forwardRef<
  HTMLElement,
  React.ComponentProps<"article">
>(function FeatureCarouselSlide({ className, ...props }, ref) {
  return (
    <article
      ref={ref}
      data-slot="feature-carousel-slide"
      className={cn("grid min-w-full md:grid-cols-[.92fr_1.08fr]", className)}
      {...props}
    />
  );
});

export function FeatureCarouselHeading({
  children,
  ...props
}: Partial<React.ComponentProps<typeof FeatureCarouselHeader>> & {
  children?: React.ReactNode;
}) {
  return (
    <FeatureCarouselHeader {...props}>
      {children !== undefined ? (
        children
      ) : (
        <div>
          <FeatureCarouselTitle>
            Make each handoff easier to understand.
          </FeatureCarouselTitle>
          <FeatureCarouselDescription>
            Three focused moments keep the project legible from first thought to
            launch.
          </FeatureCarouselDescription>
        </div>
      )}
    </FeatureCarouselHeader>
  );
}
export function FeatureCarouselSlides({
  children,
  ...props
}: Partial<React.ComponentProps<typeof FeatureCarouselContent>> & {
  children?: React.ReactNode;
}) {
  const { items, active, setActive, layout, id } = useFeatureCarousel();
  const index = Math.max(
    0,
    items.findIndex((item) => item.value === active),
  );
  if (children !== undefined)
    return (
      <FeatureCarouselContent {...props}>{children}</FeatureCarouselContent>
    );
  if (layout === "rail")
    return (
      <FeatureCarouselContent {...props}>
        <div className="flex snap-x snap-mandatory gap-4 overflow-x-auto px-5 py-5 [scrollbar-width:thin] md:px-7 md:py-7">
          {items.map((item, itemIndex) => (
            <button
              type="button"
              key={item.value}
              id={`${id}-${item.value}-rail-card`}
              aria-pressed={item.value === active}
              onClick={() => {
                setActive(item.value);
                document
                  .getElementById(`${id}-${item.value}-rail-card`)
                  ?.scrollIntoView({
                    behavior: "smooth",
                    block: "nearest",
                    inline: "center",
                  });
              }}
              className={cn(
                "group min-h-64 w-[82%] shrink-0 snap-center rounded-xl border p-5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary sm:w-[52%] lg:w-[31%]",
                item.value === active
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-border bg-muted hover:bg-background",
              )}
            >
              <span className="flex items-center justify-between text-xs">
                <span>{item.label}</span>
                <span>0{itemIndex + 1}</span>
              </span>
              <span className="mt-10 block text-2xl leading-tight tracking-[-.035em]">
                {item.title}
              </span>
              <span
                className={cn(
                  "mt-5 block text-sm leading-relaxed",
                  item.value === active
                    ? "text-primary-foreground/75"
                    : "text-muted-foreground",
                )}
              >
                {item.description}
              </span>
            </button>
          ))}
        </div>
        <div
          id={`${id}-rail-panel`}
          role="region"
          aria-label="Selected feature"
          className="border-t border-border px-5 py-5 md:px-7"
        >
          {items.find((item) => item.value === active)?.detail}
        </div>
      </FeatureCarouselContent>
    );
  return (
    <FeatureCarouselContent {...props}>
      <div
        className="flex transition-transform duration-500 ease-[cubic-bezier(.22,1,.36,1)] motion-reduce:transition-none"
        style={{ transform: `translateX(-${index * 100}%)` }}
      >
        {items.map((item, itemIndex) => (
          <FeatureCarouselSlide
            key={item.value}
            id={`${id}-${item.value}-panel`}
            role="tabpanel"
            aria-labelledby={`${id}-${item.value}-tab`}
            aria-hidden={item.value !== active}
            inert={item.value !== active}
          >
            <div className="flex min-h-72 flex-col justify-between bg-muted p-7 md:min-h-[25rem] md:p-10">
              <div className="flex items-center justify-between text-xs font-medium">
                <span>{item.label}</span>
                <span className="rounded-full border border-foreground/15 px-2 py-1">
                  0{itemIndex + 1} / 0{items.length}
                </span>
              </div>
              <div className="mt-10 rounded-lg border border-border bg-background p-5 text-foreground shadow-[0_14px_32px_rgb(0_0_0_/_0.08)]">
                <div className="flex items-center justify-between border-b border-border pb-4 text-xs">
                  <span className="font-medium">Project record</span>
                  <span className="text-muted-foreground">In review</span>
                </div>
                <p className="mt-7 text-xl tracking-[-.025em]">{item.title}</p>
                <p className="mt-3 text-sm leading-relaxed text-muted-foreground">
                  {item.detail}
                </p>
                <div className="mt-7 flex items-center gap-2 border-t border-border pt-4 text-xs">
                  <span className="size-2 rounded-full bg-primary" />
                  Decision recorded
                </div>
                {item.preview}
              </div>
            </div>
            <div className="flex min-h-72 flex-col justify-between p-7 md:min-h-[25rem] md:p-10">
              <div>
                <p className="text-xs font-medium text-primary">{item.label}</p>
                <h3 className="mt-5 max-w-lg 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>
              </div>
              <div className="flex items-center justify-between gap-4 border-t border-border pt-5 text-xs">
                <span className="text-muted-foreground">{item.detail}</span>
                {item.href ? (
                  <a
                    href={item.href}
                    className="inline-flex items-center gap-2 font-medium hover:text-primary"
                  >
                    {item.actionLabel ?? "Explore"} <ArrowUpRight size={15} />
                  </a>
                ) : item.onAction ? (
                  <button
                    type="button"
                    onClick={item.onAction}
                    className="inline-flex items-center gap-2 font-medium hover:text-primary"
                  >
                    {item.actionLabel ?? "Explore"} <ArrowUpRight size={15} />
                  </button>
                ) : null}
              </div>
            </div>
          </FeatureCarouselSlide>
        ))}
      </div>
    </FeatureCarouselContent>
  );
}
export function FeatureCarouselControls({
  children,
  ...props
}: Partial<React.ComponentProps<"div">> & { children?: React.ReactNode }) {
  const { items, active, setActive, id, layout } = useFeatureCarousel();
  const index = Math.max(
    0,
    items.findIndex((item) => item.value === active),
  );
  const select = (nextValue: string, focus = false) => {
    setActive(nextValue);
    if (layout === "rail") {
      document
        .getElementById(`${id}-${nextValue}-rail-card`)
        ?.scrollIntoView({
          behavior: "smooth",
          block: "nearest",
          inline: "center",
        });
    } else if (focus) {
      document.getElementById(`${id}-${nextValue}-tab`)?.focus();
    }
  };
  const move = (delta: number, focus = false) =>
    select(
      items[(index + delta + items.length) % items.length]?.value ?? active,
      focus,
    );
  return (
    <div
      {...props}
      data-slot="feature-carousel-controls"
      className={cn(
        "flex items-center justify-between gap-4 border-t border-border px-6 py-4",
        props.className,
      )}
    >
      {children !== undefined ? (
        children
      ) : (
        <>
          {layout === "spotlight" && (
            <div
              className="flex gap-1"
              role="tablist"
              aria-label="Feature slides"
            >
              {items.map((item, itemIndex) => (
                <button
                  type="button"
                  key={item.value}
                  id={`${id}-${item.value}-tab`}
                  role="tab"
                  aria-selected={item.value === active}
                  aria-controls={`${id}-${item.value}-panel`}
                  tabIndex={item.value === active ? 0 : -1}
                  aria-label={`Show feature ${itemIndex + 1}`}
                  onClick={() => select(item.value)}
                  onKeyDown={(event) => {
                    if (event.key === "ArrowRight") {
                      event.preventDefault();
                      move(1, true);
                    } else if (event.key === "ArrowLeft") {
                      event.preventDefault();
                      move(-1, true);
                    } else if (event.key === "Home") {
                      event.preventDefault();
                      select(items[0]?.value ?? active, true);
                    } else if (event.key === "End") {
                      event.preventDefault();
                      select(items[items.length - 1]?.value ?? active, true);
                    }
                  }}
                  className="group grid size-9 place-items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
                >
                  <span
                    className={cn(
                      "h-1 rounded-full transition-all motion-reduce:transition-none",
                      itemIndex === index
                        ? "w-6 bg-primary"
                        : "w-2 bg-border group-hover:bg-muted-foreground",
                    )}
                  />
                </button>
              ))}
            </div>
          )}
          <div className="flex gap-2">
            <button
              type="button"
              aria-label="Previous feature"
              onClick={() => move(-1)}
              disabled={items.length < 2}
              className="grid size-10 place-items-center rounded-full border border-border hover:bg-muted disabled:opacity-40"
            >
              <ArrowLeft size={16} />
            </button>
            <button
              type="button"
              aria-label="Next feature"
              onClick={() => move(1)}
              disabled={items.length < 2}
              className="grid size-10 place-items-center rounded-full border border-border hover:bg-muted disabled:opacity-40"
            >
              <ArrowRight size={16} />
            </button>
          </div>
        </>
      )}
    </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 →