Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Combination Text

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/combination-text-demo.tsx

File content: "use client";

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

export default function CombinationTextDemo() {
  return (
    <div className="flex min-h-[300px] w-full flex-col items-center justify-center gap-8 p-4 sm:min-h-[400px] sm:gap-12 sm: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>
  );
}



File location: components/mellow/combination-text.tsx

File content: "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;



Usage notes:
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).

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI