Docs

Text Shuffle

Shuffle Effect On The Text

Installation

Run the following command

It will create a new file text-shuffle.tsx inside the components/mage-ui/text directory.

mkdir -p components/mage-ui/text && touch components/mage-ui/text/text-shuffle.tsx

Paste the code

Open the newly created file and paste the following code:

 
import React, { useRef, useEffect, useState, useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
import { JSX } from 'react';
 
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
 
// ============= COMPONENT DEFINITION =============
export interface TextShuffleProps {
  text: string;
  className?: string;
  style?: React.CSSProperties;
  shuffleDirection?: 'left' | 'right';
  duration?: number;
  maxDelay?: number;
  ease?: string | ((t: number) => number);
  threshold?: number;
  rootMargin?: string;
  tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
  textAlign?: React.CSSProperties['textAlign'];
  onShuffleComplete?: () => void;
  shuffleTimes?: number;
  animationMode?: 'random' | 'evenodd';
  loop?: boolean;
  loopDelay?: number;
  stagger?: number;
  scrambleCharset?: string;
  colorFrom?: string;
  colorTo?: string;
  triggerOnce?: boolean;
  respectReducedMotion?: boolean;
  triggerOnHover?: boolean;
}
 
const TextShuffleComponent: React.FC<TextShuffleProps> = ({
  text,
  className = '',
  style = {},
  shuffleDirection = 'right',
  duration = 0.35,
  maxDelay = 0,
  ease = 'power3.out',
  threshold = 0.1,
  rootMargin = '-100px',
  tag = 'p',
  textAlign = 'center',
  onShuffleComplete,
  shuffleTimes = 1,
  animationMode = 'evenodd',
  loop = false,
  loopDelay = 0,
  stagger = 0.03,
  scrambleCharset = '',
  colorFrom,
  colorTo,
  triggerOnce = true,
  respectReducedMotion = true,
  triggerOnHover = true
}) => {
  const ref = useRef<HTMLElement>(null);
  const [fontsLoaded, setFontsLoaded] = useState(false);
  const [ready, setReady] = useState(false);
 
  const splitRef = useRef<GSAPSplitText | null>(null);
  const wrappersRef = useRef<HTMLElement[]>([]);
  const tlRef = useRef<gsap.core.Timeline | null>(null);
  const playingRef = useRef(false);
  const hoverHandlerRef = useRef<((e: Event) => void) | null>(null);
 
  useEffect(() => {
    if ('fonts' in document) {
      if (document.fonts.status === 'loaded') setFontsLoaded(true);
      else document.fonts.ready.then(() => setFontsLoaded(true));
    } else setFontsLoaded(true);
  }, []);
 
  const scrollTriggerStart = useMemo(() => {
    const startPct = (1 - threshold) * 100;
    const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
    const mv = mm ? parseFloat(mm[1]) : 0;
    const mu = mm ? mm[2] || 'px' : 'px';
    const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
    return `top ${startPct}%${sign}`;
  }, [threshold, rootMargin]);
 
  useGSAP(
    () => {
      if (!ref.current || !text || !fontsLoaded) return;
      if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        onShuffleComplete?.();
        return;
      }
 
      const el = ref.current as HTMLElement;
      const start = scrollTriggerStart;
 
      const removeHover = () => {
        if (hoverHandlerRef.current && ref.current) {
          ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
          hoverHandlerRef.current = null;
        }
      };
 
      const teardown = () => {
        if (tlRef.current) {
          tlRef.current.kill();
          tlRef.current = null;
        }
        if (wrappersRef.current.length) {
          wrappersRef.current.forEach(wrap => {
            const inner = wrap.firstElementChild as HTMLElement | null;
            const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null;
            if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
          });
          wrappersRef.current = [];
        }
        try {
          splitRef.current?.revert();
        } catch { }
        splitRef.current = null;
        playingRef.current = false;
      };
 
      const build = () => {
        teardown();
 
        const computedFont = getComputedStyle(el).fontFamily;
 
        splitRef.current = new GSAPSplitText(el, {
          type: 'chars',
          charsClass: 'shuffle-char',
          wordsClass: 'shuffle-word',
          linesClass: 'shuffle-line',
          smartWrap: true,
          reduceWhiteSpace: false
        });
 
        const chars = (splitRef.current.chars || []) as HTMLElement[];
        wrappersRef.current = [];
 
        const rolls = Math.max(1, Math.floor(shuffleTimes));
        const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
 
        chars.forEach(ch => {
          const parent = ch.parentElement;
          if (!parent) return;
 
          const w = ch.getBoundingClientRect().width;
          if (!w) return;
 
          const wrap = document.createElement('span');
          wrap.className = 'inline-block overflow-hidden align-baseline text-left';
          Object.assign(wrap.style, { width: w + 'px' });
 
          const inner = document.createElement('span');
          inner.className = 'inline-block whitespace-nowrap will-change-transform origin-left transform-gpu';
 
          parent.insertBefore(wrap, ch);
          wrap.appendChild(inner);
 
          const firstOrig = ch.cloneNode(true) as HTMLElement;
          firstOrig.className = 'inline-block text-left';
          Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
 
          ch.setAttribute('data-orig', '1');
          ch.className = 'inline-block text-left';
          Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
 
          inner.appendChild(firstOrig);
          for (let k = 0; k < rolls; k++) {
            const c = ch.cloneNode(true) as HTMLElement;
            if (scrambleCharset) c.textContent = rand(scrambleCharset);
            c.className = 'inline-block text-left';
            Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
            inner.appendChild(c);
          }
          inner.appendChild(ch);
 
          const steps = rolls + 1;
          let startX = 0;
          let finalX = -steps * w;
          if (shuffleDirection === 'right') {
            const firstCopy = inner.firstElementChild as HTMLElement | null;
            const real = inner.lastElementChild as HTMLElement | null;
            if (real) inner.insertBefore(real, inner.firstChild);
            if (firstCopy) inner.appendChild(firstCopy);
            startX = -steps * w;
            finalX = 0;
          }
 
          gsap.set(inner, { x: startX, force3D: true });
          if (colorFrom) (inner.style as any).color = colorFrom;
 
          inner.setAttribute('data-final-x', String(finalX));
          inner.setAttribute('data-start-x', String(startX));
 
          wrappersRef.current.push(wrap);
        });
      };
 
      const inners = () => wrappersRef.current.map(w => w.firstElementChild as HTMLElement);
 
      const randomizeScrambles = () => {
        if (!scrambleCharset) return;
        wrappersRef.current.forEach(w => {
          const strip = w.firstElementChild as HTMLElement;
          if (!strip) return;
          const kids = Array.from(strip.children) as HTMLElement[];
          for (let i = 1; i < kids.length - 1; i++) {
            kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
          }
        });
      };
 
      const cleanupToStill = () => {
        wrappersRef.current.forEach(w => {
          const strip = w.firstElementChild as HTMLElement;
          if (!strip) return;
          const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null;
          if (!real) return;
          strip.replaceChildren(real);
          strip.style.transform = 'none';
          strip.style.willChange = 'auto';
        });
      };
 
      const play = () => {
        const strips = inners();
        if (!strips.length) return;
 
        playingRef.current = true;
 
        const tl = gsap.timeline({
          smoothChildTiming: true,
          repeat: loop ? -1 : 0,
          repeatDelay: loop ? loopDelay : 0,
          onRepeat: () => {
            if (scrambleCharset) randomizeScrambles();
            gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });
            onShuffleComplete?.();
          },
          onComplete: () => {
            playingRef.current = false;
            if (!loop) {
              cleanupToStill();
              if (colorTo) gsap.set(strips, { color: colorTo });
              onShuffleComplete?.();
              armHover();
            }
          }
        });
 
        const addTween = (targets: HTMLElement[], at: number) => {
          tl.to(
            targets,
            {
              x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0'),
              duration,
              ease,
              force3D: true,
              stagger: animationMode === 'evenodd' ? stagger : 0
            },
            at
          );
          if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);
        };
 
        if (animationMode === 'evenodd') {
          const odd = strips.filter((_, i) => i % 2 === 1);
          const even = strips.filter((_, i) => i % 2 === 0);
          const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
          const evenStart = odd.length ? oddTotal * 0.7 : 0;
          if (odd.length) addTween(odd, 0);
          if (even.length) addTween(even, evenStart);
        } else {
          strips.forEach(strip => {
            const d = Math.random() * maxDelay;
            tl.to(
              strip,
              {
                x: parseFloat(strip.getAttribute('data-final-x') || '0'),
                duration,
                ease,
                force3D: true
              },
              d
            );
            if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
          });
        }
 
        tlRef.current = tl;
      };
 
      const armHover = () => {
        if (!triggerOnHover || !ref.current) return;
        removeHover();
        const handler = () => {
          if (playingRef.current) return;
          build();
          if (scrambleCharset) randomizeScrambles();
          play();
        };
        hoverHandlerRef.current = handler;
        ref.current.addEventListener('mouseenter', handler);
      };
 
      const create = () => {
        build();
        if (scrambleCharset) randomizeScrambles();
        play();
        armHover();
        setReady(true);
      };
 
      const st = ScrollTrigger.create({
        trigger: el,
        start,
        once: triggerOnce,
        onEnter: create
      });
 
      return () => {
        st.kill();
        removeHover();
        teardown();
        setReady(false);
      };
    },
    {
      dependencies: [
        text,
        duration,
        maxDelay,
        ease,
        scrollTriggerStart,
        fontsLoaded,
        shuffleDirection,
        shuffleTimes,
        animationMode,
        loop,
        loopDelay,
        stagger,
        scrambleCharset,
        colorFrom,
        colorTo,
        triggerOnce,
        respectReducedMotion,
        triggerOnHover,
        onShuffleComplete
      ],
      scope: ref
    }
  );
 
  const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-2xl leading-none';
  const userHasFont = useMemo(() => className && /font[-[]/i.test(className), [className]);
 
  const fallbackFont = useMemo(
    () => (userHasFont ? {} : { fontFamily: `'Press Start 2P', sans-serif` }),
    [userHasFont]
  );
 
  const commonStyle = useMemo(
    () => ({
      textAlign,
      ...fallbackFont,
      ...style
    }),
    [textAlign, fallbackFont, style]
  );
 
  const classes = useMemo(
    () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),
    [baseTw, ready, className]
  );
  const Tag = (tag || 'p') as keyof JSX.IntrinsicElements;
 
  return React.createElement(Tag, { ref: ref as any, className: classes, style: commonStyle }, text);
};
 
export const TextShuffle = TextShuffleComponent;
 
// ============= STORYBOOK META =============
const meta: Meta<typeof TextShuffle> = {
  title: 'Text/TextShuffle',
  component: TextShuffle,
  parameters: {
    layout: 'centered',
  },
  argTypes: {
    text: {
      control: 'text',
      description: 'Text to shuffle and reveal'
    },
    shuffleDirection: {
      control: 'select',
      options: ['left', 'right'],
      description: 'Direction of shuffle animation'
    },
    duration: {
      control: { type: 'number', min: 0.1, max: 2, step: 0.05 },
      description: 'Duration of animation'
    },
    animationMode: {
      control: 'select',
      options: ['evenodd', 'random'],
      description: 'Animation mode for characters'
    },
    shuffleTimes: {
      control: { type: 'number', min: 1, max: 10, step: 1 },
      description: 'Number of shuffles before revealing'
    },
    ease: {
      control: 'text',
      description: 'GSAP easing function'
    },
    stagger: {
      control: { type: 'number', min: 0, max: 0.2, step: 0.01 },
      description: 'Stagger delay between characters'
    },
    threshold: {
      control: { type: 'number', min: 0, max: 1, step: 0.1 },
      description: 'Scroll trigger threshold'
    },
    triggerOnce: {
      control: 'boolean',
      description: 'Trigger animation only once'
    },
    triggerOnHover: {
      control: 'boolean',
      description: 'Trigger on hover'
    },
  },
};
 
export default meta;
type Story = StoryObj<typeof meta>;
 
// ============= STORIES =============
export const Default: Story = {
  args: {
    text: 'HELLO WORLD',
    shuffleDirection: 'right',
    duration: 0.35,
    animationMode: 'evenodd',
    shuffleTimes: 1,
    ease: 'power3.out',
    stagger: 0.03,
    threshold: 0.1,
    triggerOnce: true,
    triggerOnHover: true,
    respectReducedMotion: true,
    className: 'text-white',
  },
};
 
export const LeftShuffle: Story = {
  args: {
    text: 'SHUFFLE LEFT',
    shuffleDirection: 'left',
    duration: 0.4,
    animationMode: 'evenodd',
    shuffleTimes: 2,
    ease: 'power2.out',
    stagger: 0.04,
    triggerOnHover: true,
    className: 'text-white text-3xl',
  },
};
 
export const RandomMode: Story = {
  args: {
    text: 'RANDOM SHUFFLE',
    shuffleDirection: 'right',
    duration: 0.45,
    animationMode: 'random',
    shuffleTimes: 3,
    maxDelay: 0.5,
    ease: 'elastic.out(1, 0.5)',
    triggerOnHover: true,
    className: 'text-white text-xl',
  },
};
 
export const FastShuffle: Story = {
  args: {
    text: 'QUICK!',
    shuffleDirection: 'right',
    duration: 0.2,
    animationMode: 'evenodd',
    shuffleTimes: 1,
    ease: 'power4.out',
    stagger: 0.015,
    triggerOnHover: true,
    className: 'text-white text-4xl font-black',
  },
};
 
export const SlowShuffle: Story = {
  args: {
    text: 'SLOW MOTION',
    shuffleDirection: 'left',
    duration: 0.8,
    animationMode: 'evenodd',
    shuffleTimes: 2,
    ease: 'power1.inOut',
    stagger: 0.08,
    triggerOnHover: true,
    className: 'text-white text-2xl',
  },
};
 
export const MultiShuffle: Story = {
  args: {
    text: 'MULTIPLE SHUFFLES',
    shuffleDirection: 'right',
    duration: 0.35,
    animationMode: 'evenodd',
    shuffleTimes: 5,
    ease: 'power3.out',
    stagger: 0.03,
    triggerOnHover: true,
    className: 'text-white text-2xl',
  },
};
 
export const ColorTransition: Story = {
  args: {
    text: 'COLOR CHANGE',
    shuffleDirection: 'right',
    duration: 0.4,
    animationMode: 'evenodd',
    shuffleTimes: 2,
    ease: 'power3.out',
    stagger: 0.03,
    colorFrom: '#ef4444',
    colorTo: '#22c55e',
    triggerOnHover: true,
    className: 'text-3xl font-bold',
  },
};
 
export const ScrambleCharset: Story = {
  args: {
    text: 'SCRAMBLED TEXT',
    shuffleDirection: 'right',
    duration: 0.35,
    animationMode: 'evenodd',
    shuffleTimes: 3,
    ease: 'power3.out',
    stagger: 0.03,
    scrambleCharset: '!@#$%^&*()_+-=[]{}|;:,.<>?',
    triggerOnHover: true,
    className: 'text-white text-2xl',
  },
};
 
export const LoopingAnimation: Story = {
  args: {
    text: 'INFINITE LOOP',
    shuffleDirection: 'right',
    duration: 0.3,
    animationMode: 'evenodd',
    shuffleTimes: 1,
    ease: 'power3.out',
    stagger: 0.025,
    loop: true,
    loopDelay: 2,
    triggerOnHover: false,
    className: 'text-white text-2xl',
  },
};
 
export const HoverOnly: Story = {
  args: {
    text: 'HOVER ME',
    shuffleDirection: 'right',
    duration: 0.3,
    animationMode: 'evenodd',
    shuffleTimes: 1,
    ease: 'power2.out',
    stagger: 0.025,
    triggerOnce: false,
    triggerOnHover: true,
    className: 'text-white text-3xl cursor-pointer',
  },
};