From 7ff25437cc23450299e4829aa2c092217f9f601d Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 03:57:48 +0000 Subject: [PATCH] feat: add variable font animation demo with gooey text effect - Created interactive variable font animation component using Google Fonts' Recursive - Implements proximity-based text distortion with smooth animations - Combines font-variation-settings with CSS transforms for enhanced gooey effect - Added comprehensive Storybook stories with interactive controls - Features real-time cursor tracking with requestAnimationFrame optimization - Includes multiple demo variations (subtle, intense, custom text, etc.) - Responsive design with fallback loading states --- .../VariableFontAnimation.stories.tsx | 159 ++++++++++++ .../experimental/VariableFontAnimation.tsx | 227 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 apps/docs/src/experimental/VariableFontAnimation.stories.tsx create mode 100644 apps/docs/src/experimental/VariableFontAnimation.tsx diff --git a/apps/docs/src/experimental/VariableFontAnimation.stories.tsx b/apps/docs/src/experimental/VariableFontAnimation.stories.tsx new file mode 100644 index 00000000..840eb034 --- /dev/null +++ b/apps/docs/src/experimental/VariableFontAnimation.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { VariableFontAnimation } from './VariableFontAnimation'; + +const meta: Meta = { + title: 'Experimental/Variable Font Animation', + component: VariableFontAnimation, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +# Variable Font Animation Demo + +This component demonstrates an interactive "gooey" text distortion effect using variable fonts and CSS animations. + +## Features + +- **Variable Font Technology**: Uses Google Fonts' Recursive font with multiple axes (weight, slant, casual) +- **Real-time Interaction**: Text distorts based on cursor proximity with smooth animations +- **Performance Optimized**: Uses requestAnimationFrame for smooth 60fps animations +- **Responsive Design**: Adapts to different screen sizes +- **Visual Effects**: Combines font variations with CSS transforms, shadows, and blur effects + +## How It Works + +1. **Cursor Tracking**: JavaScript tracks mouse movement and calculates distance to each letter +2. **Distance Mapping**: Distance is converted to intensity values (closer = higher intensity) +3. **Font Variation**: Intensity controls multiple font axes: + - **Weight**: 300-1000 (thin to ultra-bold) + - **Slant**: 0 to -15 degrees (upright to italic) + - **Casual**: 0-1 (geometric to casual style) +4. **CSS Transforms**: Additional scale and skew transforms for enhanced distortion +5. **Visual Effects**: Dynamic text shadows, blur, and glow effects + +## Technical Implementation + +- **Font Loading**: Dynamically loads Recursive variable font from Google Fonts +- **Performance**: Debounced with requestAnimationFrame for smooth animations +- **Accessibility**: Maintains readability while providing engaging interactions +- **Fallbacks**: Graceful degradation if variable fonts aren't supported + +## Inspiration + +This effect replicates the "liquid typography" trend seen in modern web design, where text behaves like a viscous fluid responding to user interaction. + +Try hovering over the letters to see the effect in action! 🎨 + `, + }, + }, + }, + argTypes: { + text: { + control: 'text', + description: 'The text to display and animate', + }, + fontSize: { + control: { type: 'range', min: 20, max: 200, step: 5 }, + description: 'Font size in pixels', + }, + maxInfluenceRadius: { + control: { type: 'range', min: 50, max: 300, step: 10 }, + description: 'Maximum distance in pixels where cursor affects letters', + }, + animationSpeed: { + control: { type: 'range', min: 0.05, max: 0.5, step: 0.05 }, + description: 'Animation transition speed in seconds', + }, + distortionIntensity: { + control: { type: 'range', min: 0.1, max: 3, step: 0.1 }, + description: 'Intensity multiplier for distortion effects', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: 'LAMBDA CURRY', + fontSize: 80, + maxInfluenceRadius: 120, + animationSpeed: 0.1, + distortionIntensity: 1, + }, +}; + +export const LargeText: Story = { + args: { + text: 'GOOEY', + fontSize: 120, + maxInfluenceRadius: 150, + animationSpeed: 0.08, + distortionIntensity: 1.5, + }, +}; + +export const SubtleEffect: Story = { + args: { + text: 'Subtle Animation', + fontSize: 60, + maxInfluenceRadius: 80, + animationSpeed: 0.15, + distortionIntensity: 0.5, + }, +}; + +export const IntenseDistortion: Story = { + args: { + text: 'EXTREME', + fontSize: 100, + maxInfluenceRadius: 200, + animationSpeed: 0.05, + distortionIntensity: 2.5, + }, +}; + +export const CustomMessage: Story = { + args: { + text: 'HELLO WORLD', + fontSize: 70, + maxInfluenceRadius: 100, + animationSpeed: 0.12, + distortionIntensity: 1.2, + }, +}; + +export const SmallText: Story = { + args: { + text: 'Variable Fonts Rock!', + fontSize: 40, + maxInfluenceRadius: 60, + animationSpeed: 0.2, + distortionIntensity: 0.8, + }, +}; + +// Interactive playground story +export const Playground: Story = { + args: { + text: 'PLAY WITH ME', + fontSize: 90, + maxInfluenceRadius: 140, + animationSpeed: 0.1, + distortionIntensity: 1.3, + }, + parameters: { + docs: { + description: { + story: 'Use the controls below to experiment with different settings and see how they affect the animation!', + }, + }, + }, +}; + diff --git a/apps/docs/src/experimental/VariableFontAnimation.tsx b/apps/docs/src/experimental/VariableFontAnimation.tsx new file mode 100644 index 00000000..3b55c218 --- /dev/null +++ b/apps/docs/src/experimental/VariableFontAnimation.tsx @@ -0,0 +1,227 @@ +import React, { useEffect, useRef, useState } from 'react'; + +interface VariableFontAnimationProps { + text?: string; + fontSize?: number; + maxInfluenceRadius?: number; + animationSpeed?: number; + distortionIntensity?: number; + className?: string; +} + +export const VariableFontAnimation: React.FC = ({ + text = 'LAMBDA CURRY', + fontSize = 80, + maxInfluenceRadius = 120, + animationSpeed = 0.1, + distortionIntensity = 1, + className = '', +}) => { + const containerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + // Load the Recursive variable font from Google Fonts + const link = document.createElement('link'); + link.href = 'https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap'; + link.rel = 'stylesheet'; + document.head.appendChild(link); + + link.onload = () => setIsLoaded(true); + + return () => { + if (document.head.contains(link)) { + document.head.removeChild(link); + } + }; + }, []); + + useEffect(() => { + if (!isLoaded || !containerRef.current) return; + + const container = containerRef.current; + const letters = container.querySelectorAll('.gooey-letter') as NodeListOf; + + let animationFrameId: number; + + const handleMouseMove = (e: MouseEvent) => { + cancelAnimationFrame(animationFrameId); + + animationFrameId = requestAnimationFrame(() => { + letters.forEach((letter) => { + const rect = letter.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const distance = Math.sqrt( + Math.pow(centerX - e.clientX, 2) + Math.pow(centerY - e.clientY, 2) + ); + + // Calculate intensity based on distance (inverse relationship) + const normalizedDistance = Math.max(0, 1 - (distance / maxInfluenceRadius)); + const intensity = normalizedDistance * distortionIntensity; + + // Map intensity to font variation settings + const weight = 300 + (intensity * 700); // 300-1000 weight range + const slant = intensity * -15; // 0 to -15 slant + const casual = intensity * 1; // 0 to 1 casual axis + + // Apply CSS transforms for additional gooey effect + const scale = 1 + (intensity * 0.3); // Scale up to 1.3x + const skewX = intensity * 10; // Skew for distortion + const skewY = intensity * 5; + + // Update CSS custom properties + letter.style.setProperty('--font-weight', weight.toString()); + letter.style.setProperty('--font-slant', slant.toString()); + letter.style.setProperty('--font-casual', casual.toString()); + letter.style.setProperty('--scale', scale.toString()); + letter.style.setProperty('--skew-x', `${skewX}deg`); + letter.style.setProperty('--skew-y', `${skewY}deg`); + letter.style.setProperty('--intensity', intensity.toString()); + }); + }); + }; + + const handleMouseLeave = () => { + cancelAnimationFrame(animationFrameId); + + // Reset all letters to default state + letters.forEach((letter) => { + letter.style.setProperty('--font-weight', '400'); + letter.style.setProperty('--font-slant', '0'); + letter.style.setProperty('--font-casual', '0'); + letter.style.setProperty('--scale', '1'); + letter.style.setProperty('--skew-x', '0deg'); + letter.style.setProperty('--skew-y', '0deg'); + letter.style.setProperty('--intensity', '0'); + }); + }; + + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + return () => { + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + cancelAnimationFrame(animationFrameId); + }; + }, [isLoaded, maxInfluenceRadius, distortionIntensity]); + + const letters = text.split('').map((char, index) => ( + + {char === ' ' ? '\u00A0' : char} + + )); + + return ( +
+ + +
+ {letters} +
+ + {!isLoaded && ( +
+ Loading font... +
+ )} +
+ ); +}; + +export default VariableFontAnimation; +