All components

Phototab

Phototab is a React and Tailwind CSS navigation component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT navigation

What it is

Phototab is a React and Tailwind CSS navigation component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion @radix-ui/react-tabs

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Phototab

index.tsx
"use client";

import {
  Content as TabsContent,
  List as TabsList,
  Root as TabsRoot,
  Trigger as TabsTrigger,
} from "@radix-ui/react-tabs";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * Tab definition for Phototab
 */
export interface PhototabTab {
  /** Tab icon (ReactNode) */
  icon: React.ReactNode;
  /** Tab image (string: URL or import) */
  image: string;
  /** Tab label */
  name: string;
}

export interface PhototabProps {
  /** Class name for root */
  className?: string;
  /** Default selected tab name */
  defaultTab?: string;
  /** Height of the component in pixels */
  height?: number;
  /** Class name for image */
  imageClassName?: string;
  /** Class name for tab list */
  tabListClassName?: string;
  /** Array of tabs to display */
  tabs: PhototabTab[];
  /** Class name for tab trigger */
  tabTriggerClassName?: string;
}

export default function Phototab({
  tabs,
  defaultTab,
  height = 400,
  className = "",
  tabListClassName = "",
  tabTriggerClassName = "",
  imageClassName = "",
}: PhototabProps) {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
  const [bgStyle, setBgStyle] = useState<{
    left: number;
    top: number;
    width: number;
    height: number;
  } | null>(null);
  const triggersRef = useRef<(HTMLButtonElement | null)[]>([]);
  const listRef = useRef<HTMLDivElement | null>(null);
  const shouldReduceMotion = useReducedMotion();

  useLayoutEffect(() => {
    if (
      hoveredIndex !== null &&
      triggersRef.current[hoveredIndex] &&
      listRef.current
    ) {
      const trigger = triggersRef.current[hoveredIndex];
      if (!trigger) {
        return;
      }
      const listRect = listRef.current.getBoundingClientRect();
      const triggerRect = trigger.getBoundingClientRect();
      setBgStyle({
        height: triggerRect.height,
        left: triggerRect.left - listRect.left,
        top: triggerRect.top - listRect.top,
        width: triggerRect.width,
      });
    } else {
      setBgStyle(null);
    }
  }, [hoveredIndex]);

  return (
    <TabsRoot
      className={`group relative aspect-square w-auto overflow-hidden ${className}`}
      defaultValue={defaultTab || (tabs[0]?.name ?? "")}
      orientation="horizontal"
      style={{ height: `${height}px` }}
    >
      <TabsList
        aria-label="Phototab Tabs"
        className={`absolute right-0 bottom-2 left-0 mx-auto flex w-40 -translate-y-10 flex-row items-center justify-between rounded-full bg-primary/40 px-3 py-2 font-medium text-sm ring ring-border/70 backdrop-blur-sm transition hover:text-foreground md:translate-y-20 md:group-hover:translate-y-0 ${tabListClassName}`}
        ref={listRef}
        style={{ pointerEvents: "auto" }}
      >
        <AnimatePresence>
          {bgStyle ? (
            <motion.span
              animate={{
                height: bgStyle.height,
                left: bgStyle.left,
                opacity: 1,
                top: bgStyle.top,
                width: bgStyle.width,
              }}
              className="absolute z-0 rounded-full bg-primary transition-colors"
              exit={{ opacity: 0 }}
              initial={{ opacity: 0 }}
              layoutId="hoverBackground"
              style={{ position: "absolute" }}
              transition={
                shouldReduceMotion
                  ? { duration: 0 }
                  : {
                      damping: 40,
                      duration: 0.25,
                      stiffness: 400,
                      type: "spring" as const,
                    }
              }
            />
          ) : null}
        </AnimatePresence>
        {tabs.map((tab, index) => (
          <TabsTrigger
            aria-label={tab.name}
            className={`relative z-10 cursor-pointer rounded-full p-2 data-[state='active']:bg-background ${tabTriggerClassName}`}
            key={tab.name}
            onMouseEnter={() => {
              setHoveredIndex(index);
            }}
            onMouseLeave={() => {
              setHoveredIndex(null);
            }}
            ref={(el) => {
              triggersRef.current[index] = el;
            }}
            value={tab.name}
          >
            <span className="relative z-10 rounded-full focus:outline-none">
              {tab.icon}
              <span className="sr-only">{tab.name}</span>
            </span>
          </TabsTrigger>
        ))}
      </TabsList>
      {tabs.map((tab) => (
        <TabsContent className="h-full w-full" key={tab.name} value={tab.name}>
          <img
            alt={tab.name}
            className={`h-full w-full rounded-2xl bg-primary object-cover ${imageClassName}`}
            draggable={false}
            height={height}
            loading="lazy"
            src={tab.image}
            width={400}
          />
        </TabsContent>
      ))}
    </TabsRoot>
  );
}

Similar components