# Combination Text (`combination-text`)

> A mechanical combination lock that spins through characters to form words.

- **Docs:** https://www.mellowui.com/components/combination-text
- **Markdown:** https://www.mellowui.com/components/combination-text.md
- **Registry:** https://www.mellowui.com/r/combination-text.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/combination-text
- **Categories:** text, animation
- **Dependencies:** motion

## AI prompt

Add a CombinationText component from the mellow library. It cycles through an array of strings like a mechanical combination lock, spinning individual characters into place. Key props: texts (string[]), interval (ms).

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/combination-text.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `texts` | `string[]` | — | Array of strings to cycle through. |
| `interval` | `number` | `4000` | Time between word changes in milliseconds. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useTransform,
  useVelocity,
  useSpring,
  useMotionTemplate,
  useReducedMotion,
  animate,
} from "motion/react";

const ALPHABET =
  " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?@#&()-_=+/'\"".split("");

const TUMBLER_REPEATS = 13;
const N = ALPHABET.length;
const MIN_ROW = 2 * N;
const MAX_ROW = (TUMBLER_REPEATS - 2) * N - 1;
const SEED_CENTER_BAND = 6;

interface CombinationTextProps {
  texts: string[];
  interval?: number;
  className?: string;
}

export function CombinationText({
  texts,
  interval = 5000,
  className = "",
}: CombinationTextProps) {
  const [textIndex, setTextIndex] = useState(0);

  useEffect(() => {
    if (texts.length <= 1) return;
    const timer = setInterval(() => {
      setTextIndex((prev) => (prev + 1) % texts.length);
    }, interval);
    return () => clearInterval(timer);
  }, [texts, interval]);

  const maxLength = Math.max(...texts.map((t) => t.length));
  const currentText = texts[textIndex].padEnd(maxLength, " ");

  return (
    <div className={`inline-flex gap-[3px] ${className}`}>
      {currentText.split("").map((char, i) => (
        <CombinationSlot
          key={i}
          targetChar={char}
          index={i}
          total={maxLength}
        />
      ))}
    </div>
  );
}

function CombinationSlot({
  targetChar,
  index,
  total,
}: {
  targetChar: string;
  index: number;
  total: number;
}) {
  const reducedMotion = useReducedMotion();
  const seededRef = useRef(false);

  const y = useMotionValue(0);
  const yEm = useTransform(y, (v) => `${v}em`);

  const yVelocity = useVelocity(y);
  const smoothVelocity = useSpring(yVelocity, {
    damping: 30,
    stiffness: 600,
    mass: 0.1,
  });
  const blurEm = useTransform(smoothVelocity, (v) =>
    Math.min(Math.abs(v) * 0.0015, 0.09)
  );
  const blurFilter = useMotionTemplate`blur(${blurEm}em)`;

  const scaleY = useTransform(smoothVelocity, (v) =>
    Math.max(1 - Math.min(Math.abs(v) * 0.0008, 0.04), 0.96)
  );

  useEffect(() => {
    const targetIdx = ALPHABET.indexOf(targetChar.toUpperCase());
    const finalTargetIdx = targetIdx === -1 ? 0 : targetIdx;

    if (reducedMotion) {
      y.jump(-(SEED_CENTER_BAND * N + finalTargetIdx));
      return;
    }

    if (!seededRef.current) {
      y.jump(-(SEED_CENTER_BAND * N + 0));
      seededRef.current = true;
    }

    let row = Math.round(-y.get());
    const curMod = ((row % N) + N) % N;

    y.jump(-row);

    const tgt = finalTargetIdx;
    const scrambleDown = Math.random() < 0.5;

    let dFwd = (tgt - curMod + N) % N;
    if (dFwd === 0) dFwd = N;
    let dBack = (curMod - tgt + N) % N;
    if (dBack === 0) dBack = N;

    const extraSpins = (1 + Math.floor(Math.random() * 2)) * N;

    let rowEnd = scrambleDown
      ? row + dFwd + extraSpins
      : row - dBack - extraSpins;

    while (rowEnd < MIN_ROW) rowEnd += N;
    while (rowEnd > MAX_ROW) rowEnd -= N;

    const cascadeFactor = total > 1 ? index / (total - 1) : 0;
    const delay = index * 0.07;
    const duration = 1.6 + cascadeFactor * 0.7 + Math.random() * 0.18;

    animate(y, -rowEnd, {
      type: "spring",
      bounce: 0.22,
      duration,
      delay,
    });
  }, [targetChar, index, total, y, reducedMotion]);

  const column = Array.from({ length: TUMBLER_REPEATS }).flatMap(() => ALPHABET);

  return (
    <div
      className="relative inline-block h-[1.6em] w-[1em] overflow-hidden rounded-[4px] border border-(--rule) bg-[rgba(var(--ink-rgb),0.02)]"
      style={{
        boxShadow:
          "inset 0 1px 3px rgba(0,0,0,0.25), inset 0 -1px 3px rgba(0,0,0,0.25), inset 0 0 0 1px rgba(255,255,255,0.03)",
      }}
    >
      <div className="pointer-events-none absolute inset-x-0 top-0 z-30 h-[35%] bg-linear-to-b from-[rgba(255,255,255,0.12)] via-[rgba(255,255,255,0.02)] to-transparent mix-blend-overlay" />

      <div className="pointer-events-none absolute inset-0 z-10 bg-linear-to-b from-[rgba(0,0,0,0.6)] via-transparent to-[rgba(0,0,0,0.6)]" />

      <div
        className="pointer-events-none absolute inset-x-[8%] top-1/2 z-20 h-[2px] -translate-y-1/2"
        style={{
          background:
            "linear-gradient(to bottom, rgba(var(--ink-rgb),0.15) 0%, rgba(var(--ink-rgb),0.05) 100%)",
          boxShadow: "0 1px 0 rgba(255,255,255,0.05)",
        }}
      />

      <div
        className="pointer-events-none absolute inset-0 z-20 opacity-[0.15] mix-blend-overlay"
        style={{
          backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
        }}
      />

      <motion.div
        className="absolute inset-0 will-change-[filter,transform]"
        style={{
          maskImage:
            "linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%)",
          WebkitMaskImage:
            "linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%)",
          filter: blurFilter,
          scaleY,
        }}
      >
        <motion.div
          className="absolute left-0 flex w-full flex-col items-center"
          style={{ y: yEm, top: "0.3em" }}
        >
          {column.map((char, i) => (
            <span
              key={i}
              className="flex h-[1em] items-center justify-center font-mono font-medium leading-none text-(--ink)"
              style={{
                textShadow: "0 1px 1px rgba(0,0,0,0.3)",
              }}
            >
              {char}
            </span>
          ))}
        </motion.div>
      </motion.div>
    </div>
  );
}

export default CombinationText;

```

## Demo

```tsx
"use client";

import { CombinationText } from "@/registry/mellow/combination-text";

export default function CombinationTextDemo() {
  return (
    <div className="flex min-h-[400px] w-full flex-col items-center justify-center gap-12 p-8">
      <div className="text-3xl md:text-5xl">
        <CombinationText
          texts={["CREATE", "DESIGN", "DEPLOY", "SECURE"]}
          interval={3000}
        />
      </div>

      <div className="flex items-center gap-3 text-sm text-(--ink) opacity-70">
        <span className="font-mono uppercase tracking-widest">System Status:</span>
        <CombinationText
          texts={["ONLINE", "ACTIVE", "STABLE"]}
          interval={4000}
          className="text-base"
        />
      </div>
    </div>
  );
}

```
