Index

Label Indicators

Standard carousel indicators are dots. Five items, five dots. You know which position you're at, but not what you're looking at. If you want to jump to a specific item, you either remember its position or tap through until you find it.

What if the active indicator could show you the item's name?

The interaction

Click the indicators below. The active one expands into a pill with a label. The inactive ones collapse back to minimal dots.

The expansion happens in place — the pill grows outward from the dot's center. The label fades in with a slight blur, giving the sense that it's emerging from within rather than appearing on top.

Why blur on entry?

The label animates with both opacity and a blur filter:

<motion.span
  animate={{
    opacity: currentIndex === index ? 1 : 0,
    scale: currentIndex === index ? 1 : 0,
    filter: currentIndex === index 
      ? "blur(0px)" 
      : "blur(4px)",
  }}
  transition={transition}
>
  {item}
</motion.span>

Without the blur, the text pops in too sharply. It reads as something being added to the indicator. With the blur, the text feels like it was always there, just coming into focus. The pill isn't gaining a label — the label is being revealed.

The spring config

Both the cards and indicators share the same physics:

const transition = {
  type: "spring",
  stiffness: 260,
  damping: 28,
};

This creates cohesion. When you tap an indicator, the cards slide, the active card drops down, and the pill expands — all with matching momentum. They feel like parts of a single system rather than independent animations.

The inactive cards rise up and fade slightly, creating a peek effect where you can see what's next even before you get there.

When to use this

This pattern works best when carousel items have short, meaningful names. Image galleries with captions. Feature tours. Onboarding steps. Product categories.

It doesn't work as well for items without distinct identities, or when names are long. Five words won't fit in a pill. But for concise labels — "Design", "Motion", "Code" — it turns passive scrolling into direct navigation.


Dots tell you where you are. Labels tell you what you're looking at. When both fit, you should probably show both.

"use client";
 
import { useState } from "react";
import { motion } from "motion/react";
 
const transition = {
  type: "spring",
  stiffness: 260,
  damping: 28,
};
 
const ITEMS = ["Design", "Motion", "Code", "Ship", "Iterate"];
const CARD_WIDTH = 200;
const CARD_GAP = 16;
const CARD_OFFSET = CARD_WIDTH + CARD_GAP;
 
export function LabelIndicatorCarousel() {
  const [currentIndex, setCurrentIndex] = useState(0);
 
  return (
    <div className="carousel-container">
      <div className="carousel-stage">
        <motion.div
          className="carousel-track"
          animate={{ x: -currentIndex * CARD_OFFSET }}
          transition={transition}
        >
          {ITEMS.map((item, index) => {
            const isActive = currentIndex === index;
            return (
              <motion.div
                key={index}
                className="carousel-card"
                animate={{ 
                  y: isActive ? 0 : -70,
                  opacity: isActive ? 1 : 0.5,
                  scale: isActive ? 1 : 0.95,
                }}
                transition={transition}
                onClick={() => setCurrentIndex(index)}
              >
                <span className="carousel-card-label">{item}</span>
              </motion.div>
            );
          })}
        </motion.div>
      </div>
      <div className="carousel-indicators">
        {ITEMS.map((item, index) => {
          const isActive = currentIndex === index;
          return (
            <motion.button
              key={index}
              className="carousel-indicator"
              onClick={() => setCurrentIndex(index)}
              animate={{
                width: isActive ? 68 : 12,
                height: isActive ? 26 : 12,
              }}
              transition={transition}
            >
              <motion.span
                className="carousel-indicator-label"
                animate={{
                  opacity: isActive ? 1 : 0,
                  scale: isActive ? 1 : 0,
                  filter: isActive ? "blur(0px)" : "blur(4px)",
                }}
                transition={transition}
              >
                {item}
              </motion.span>
            </motion.button>
          );
        })}
      </div>
    </div>
  );
}