Text animations

Fluid Text

An interactive text component that smoothly distorts and scales characters based on mouse proximity, using a normalization algorithm to ensure the total width remains constant.

'use client';import { FluidText } from '@/components/satisui/fluid-text';import { cn } from '@/lib/utils';import { Antonio } from 'next/font/google';const displayFont = Antonio({weight: '700',subsets: ['latin'],display: 'swap',});export const FluidTextHero = () => {return (  <div    className={cn(      'flex min-h-[400px] w-full items-center justify-center rounded-lg border bg-background p-8',      displayFont.className,    )}  >    <FluidText      text='SATIS UI.'      className='text-7xl font-extrabold text-foreground md:text-[256px] '      maxScale={4.5}      radius={300}    />  </div>);};

Installation

npx shadcn@latest add https://satisui.xyz/r/fluid-text.json

Manual

No external dependencies are required.

Copy the code below into components/satisui/fluid-text.tsx.

'use client';

import React, { useEffect, useRef, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';

interface FluidTextProps {
  /**
   * The text content to display.
   */
  text: string;
  /**
   * Optional class names for the container (controls font-size, weight, color).
   */
  className?: string;
  /**
   * The maximum scale factor for the hovered character.
   * @default 2.0
   */
  maxScale?: number;
  /**
   * The radius of the distortion effect in pixels.
   * @default 200
   */
  radius?: number;
  /**
   * Physics stiffness: 0.1 (Liquid) to 0.9 (Snappy).
   * @default 0.15
   */
  stiffness?: number;
  /**
   * Duration of the fade-in animation in milliseconds.
   * @default 700
   */
  fadeDuration?: number;
}

export function FluidText({
  text,
  className,
  maxScale = 2.0,
  radius = 200,
  stiffness = 0.15,
  fadeDuration = 700,
}: FluidTextProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const spansRef = useRef<(HTMLSpanElement | null)[]>([]);

  // Controls visual visibility (Fade in) to prevent FOUT
  const [isReady, setIsReady] = useState(false);

  // Internal high-performance state (No React Rerenders)
  const state = useRef({
    charWidths: [] as number[],
    totalNaturalWidth: 0,
    isMeasured: false,
    targetX: -9999,
    currentX: -9999,
    targetIntensity: 0,
    currentIntensity: 0,
  });

  const chars = useMemo(() => text.split(''), [text]);

  // 1. Measurement System
  useEffect(() => {
    if (!containerRef.current) return;

    const measure = () => {
      if (!containerRef.current) return;

      // Reset transforms temporarily
      spansRef.current.forEach((span) => {
        if (span) span.style.transform = 'none';
      });

      // Measure every character width
      const widths = spansRef.current
        .filter((s): s is HTMLSpanElement => s !== null)
        .map((s) => s.getBoundingClientRect().width);

      if (widths.length === 0) return;

      state.current.charWidths = widths;
      state.current.totalNaturalWidth = widths.reduce((sum, w) => sum + w, 0);
      state.current.isMeasured = true;

      // Trigger fade-in
      setIsReady(true);
    };

    // Wait for fonts to be ready
    document.fonts.ready.then(measure);

    // Handle Resize
    const observer = new ResizeObserver(measure);
    observer.observe(containerRef.current);

    return () => observer.disconnect();
  }, [text]);

  // 2. Physics & Layout Engine
  useEffect(() => {
    let frameId: number;

    const loop = () => {
      // Optimization: Stop loop if not measured or unmounted
      if (!state.current.isMeasured || !containerRef.current) {
        frameId = requestAnimationFrame(loop);
        return;
      }

      const s = state.current;

      // --- PHYSICS ---
      // 1. Lerp Position
      const posDelta = s.targetX - s.currentX;
      s.currentX += posDelta * stiffness;

      // 2. Lerp Intensity
      const intDelta = s.targetIntensity - s.currentIntensity;
      s.currentIntensity += intDelta * 0.08;

      // --- LAYOUT ---
      let totalDesiredWidth = 0;
      const desiredScales: number[] = [];

      // Pass 1: Calculate desired width based on Mouse Proximity
      s.charWidths.forEach((w, i) => {
        // Approximate center based on accumulated natural widths
        let offset = 0;
        for (let j = 0; j < i; j++) offset += s.charWidths[j];
        const charCenter = offset + w / 2;

        const dist = Math.abs(charCenter - s.currentX);

        let desiredScale = 1.0;
        if (dist < radius) {
          const normalized = dist / radius;
          // Cosine interpolation for organic shape
          const curve = Math.cos(normalized * (Math.PI / 2));
          desiredScale = 1 + (maxScale - 1) * curve * s.currentIntensity;
        }

        desiredScales[i] = desiredScale;
        totalDesiredWidth += w * desiredScale;
      });

      // Pass 2: Calculate Normalization Factor (k)
      // Ensures content strictly fits inside the original container width
      const k =
        totalDesiredWidth === 0 ? 1 : s.totalNaturalWidth / totalDesiredWidth;

      // Pass 3: Apply Transforms
      let currentLayoutX = 0;
      spansRef.current.forEach((span, i) => {
        if (!span) return;
        const w = s.charWidths[i];
        const finalScale = desiredScales[i] * k;

        // Hardware acceleration
        span.style.transform = `translate3d(${currentLayoutX.toFixed(2)}px, 0, 0) scaleX(${finalScale.toFixed(3)})`;

        currentLayoutX += w * finalScale;
      });

      frameId = requestAnimationFrame(loop);
    };

    loop();
    return () => cancelAnimationFrame(frameId);
  }, [maxScale, radius, stiffness]);

  const onMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    state.current.targetX = e.clientX - rect.left;

    // Instant snap on entry
    if (state.current.targetIntensity === 0) {
      state.current.currentX = e.clientX - rect.left;
    }
    state.current.targetIntensity = 1;
  };

  const onMouseLeave = () => {
    state.current.targetIntensity = 0;
  };

  return (
    <div
      ref={containerRef}
      className={cn(
        'relative flex h-auto cursor-default select-none items-center justify-center whitespace-nowrap',
        className,
      )}
      onMouseMove={onMouseMove}
      onMouseLeave={onMouseLeave}
      aria-label={text}
    >
      {/* 
         LAYER 1: Animated Content
      */}
      <div
        className={cn(
          'absolute inset-0 flex items-center justify-center will-change-opacity',
          isReady ? 'opacity-100' : 'opacity-0',
        )}
        style={{ transition: `opacity ${fadeDuration}ms ease-out` }}
        aria-hidden='true'
      >
        {chars.map((char, i) => (
          <span
            key={i}
            ref={(el) => {
              spansRef.current[i] = el;
            }}
            className='absolute left-0 top-0 inline-block origin-left text-center'
            style={{
              backfaceVisibility: 'hidden',
              WebkitFontSmoothing: 'antialiased',
              transformStyle: 'preserve-3d',
            }}
          >
            {char === ' ' ? '\u00A0' : char}
          </span>
        ))}
      </div>

      {/* 
         LAYER 2: Invisible Clone (Layout Reservation)
         This ensures the container has the correct width/height immediately.
      */}
      <span
        className='invisible opacity-0 pointer-events-none whitespace-pre leading-none'
        aria-hidden='true'
      >
        {text}
      </span>
    </div>
  );
}

Usage

Use the FluidText component to create interactive typography that distorts on hover. It automatically fits within its parent container.

import { FluidText } from '@/components/ui/fluid-text';

export function FluidTextDemo() {
  return (
    <div className='flex min-h-[300px] w-full flex-col items-center justify-center gap-10 bg-zinc-950 p-8'>
      {/* Basic Example */}
      <div className='w-full max-w-xl border-b border-zinc-800 pb-2'>
        <FluidText text='DEFAULT' className='text-6xl font-bold text-white' />
      </div>

      {/* Highly Customized Example */}
      <div className='w-full max-w-2xl border-b border-zinc-800 pb-2'>
        <FluidText
          text='LIQUID INTERACTION'
          className='text-5xl font-black text-blue-500'
          maxScale={3.5}
          radius={300}
          stiffness={0.1}
          fadeDuration={1000}
        />
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
textstringRequiredThe text content to display.
classNamestringundefinedOptional class names for the container (controls font-size, weight, color).
maxScalenumber2.0The maximum scale factor for the hovered character.
radiusnumber200The radius of the distortion effect in pixels.
stiffnessnumber0.15Physics stiffness: 0.1 (Liquid) to 0.9 (Snappy).
fadeDurationnumber700Duration of the fade-in animation in milliseconds.