Skip to content

Photo Tabs

Photos that are the tabs: the chosen one takes a fixed share, the others share the rest, and a panel cross-fades beside them.

$npx shadcn@latest add https://framebrick.co/r/photo-tabs.json

From the Ciketo template, see it in context.

“They turned a vague idea into a site our whole team could edit on day one. We have not called a developer since.”
Amara Okafor · Head of Product, Lumen

Installation

$ npx shadcn@latest add https://framebrick.co/r/photo-tabs.json

Usage

import { PhotoTabs } from "@/components/ui/photo-tabs";

<PhotoTabs
  label="Client testimonials"
  items={people.map((p) => ({
    value: p.slug,
    label: p.name,
    media: <Image src={p.photo} alt="" fill />,
    panel: <Quote name={p.name} role={p.role}>{p.quote}</Quote>,
  }))}
/>

Props

PropTypeDefaultDescription
items{ value, label, media, panel }[]—The tabs. `media` is the photo, `label` names the tab for assistive technology, `panel` is what shows beside the photos.
labelstring—Accessible name of the tab list.
valuestring—Chosen tab (controlled).
defaultValuestringfirst tabTab that starts chosen (uncontrolled).
onValueChange(value: string) => void—Called when another tab is chosen.
selectedSharenumber55How much of the strip the chosen photo takes, in percent.
heightnumber480Height of the photo column from 768 px up, in px.
thumbWidthnumber168Width of the photo column from 768 px up, in px.
  • The mirror of `expanding-panels`, where the chosen one is the flexible one and the rest are fixed, and the other way round from `step-sequence`, where text steps drive a media panel. It fits testimonials, team and products.
  • A column from 768 px up, a row below. The sizes are flex-basis in percent along the strip's main axis, which is the height in the column and the width in the row, so one value is right in both with no breakpoint logic in JavaScript.
  • A real tab list: arrow keys, Home and End move between the tabs. The panels that are not shown are inert as well as aria-hidden. Click only, as in Ciketo; there is no timer.
  • Reduced motion is a zero-duration transition, not different markup: flex sizes are not a transform, so MotionConfig would not skip them, and markup that depended on it would fail hydration.
  • The portraits in the demo are Unsplash photos shown with invented names and quotes, as placeholders. Use your own clients' photos and words: a stock portrait beside a quote reads as that person's endorsement.

AI prompt

Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.

Create a React + Tailwind CSS + Motion (motion/react) component called PhotoTabs.
- A flex container. Below 768px the photo strip is a 160px-tall row above the panel; from 768px up the strip is a column
  on the left (default 168px wide, 480px tall) with the panel on its right, gap-10.
- The strip is role tablist with an aria-label. Each photo is a motion.button role tab (aria-selected, aria-label = the
  person's name, roving tabindex, aria-controls the panel), rounded-lg bg-muted, overflow-hidden, children stretched with
  object-cover.
- Exactly one tab is chosen (the first by default). The chosen tab animates to flex-grow 0 and flex-basis 55%; the others
  to flex-grow 1 and flex-basis 0%, so they share what is left. Percent basis follows the strip's main axis: the height in
  the column, the width in the row, so no breakpoint logic is needed. Spring: bounce 0, duration 0.4s.
- Arrow keys (Up/Left previous, Down/Right next), Home and End move and choose, wrapping around.
- The panel beside is role tabpanel with aria-labelledby the chosen tab. Put every panel in one CSS grid cell (all
  col-start-1 row-start-1) so the tallest sets the height; fade the chosen one to opacity 1 and the rest to 0 with the
  same spring, and mark the rest inert and aria-hidden.
- Reduced motion: use useReducedMotion() only to switch the transition to zero duration — never to change the markup,
  because the server cannot know it and a different render is a hydration mismatch.

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy