Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions apps/docs/src/experimental/VariableFontAnimation.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from '@storybook/react';
import { VariableFontAnimation } from './VariableFontAnimation';
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ› ๏ธ Refactor suggestion

Add required imports for comprehensive Storybook testing.

According to established patterns, stories should include imports for interaction testing to serve as both documentation and automated tests.

Add missing testing imports:

 import type { Meta, StoryObj } from '@storybook/react';
+import { expect, userEvent, within } from '@storybook/test';
+import { step } from '@storybook/test';
 import { VariableFontAnimation } from './VariableFontAnimation';

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In apps/docs/src/experimental/VariableFontAnimation.stories.tsx at the top of
the file, add the necessary imports for Storybook interaction testing, such as
'userEvent' from '@storybook/testing-library' and 'expect' from
'@storybook/jest'. This will enable writing interaction tests within the
stories, improving documentation and automated test coverage.


const meta: Meta<typeof VariableFontAnimation> = {
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<typeof meta>;

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!',
},
},
},
};
Comment on lines +82 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ก Verification agent

๐Ÿงฉ Analysis chain

Add play functions for interaction testing and accessibility.

Stories should serve as both documentation and automated tests. Consider adding play functions to test user interactions and accessibility.

Add interactive testing to stories:

 export const Default: Story = {
   args: {
     text: 'LAMBDA CURRY',
     fontSize: 80,
     maxInfluenceRadius: 120,
     animationSpeed: 0.1,
     distortionIntensity: 1,
   },
+  play: async ({ canvasElement, step }) => {
+    const canvas = within(canvasElement);
+    
+    await step('Verify component renders', async () => {
+      const textContainer = await canvas.findByRole('generic');
+      await expect(textContainer).toBeInTheDocument();
+    });
+    
+    await step('Test mouse interaction', async () => {
+      const textContainer = canvas.getByText('LAMBDA CURRY');
+      await userEvent.hover(textContainer);
+      // Verify font variations are applied (check CSS custom properties)
+    });
+    
+    await step('Test keyboard accessibility', async () => {
+      // Test keyboard navigation if applicable
+      await userEvent.tab();
+    });
+  },
 };

Add similar testing patterns to other stories, especially the Playground story for comprehensive interaction testing.


๐Ÿ Script executed:

#!/bin/bash
# Check for existing play functions in the story file
rg -n "play\s*:" apps/docs/src/experimental/VariableFontAnimation.stories.tsx || echo "No play functions found"

Length of output: 136


Add play functions to enable interaction testing and accessibility

I ran a search and confirmed there are no play: functions in apps/docs/src/experimental/VariableFontAnimation.stories.tsx. To align with our Storybook testing guidelines, each story should include a play function that:

  • Uses the three-phase pattern (initial render, interaction, accessibility/navigation).
  • Leverages semantic queries (findByText, getByRole, etc.) over CSS selectors.
  • Organizes steps via the step helper for clear, debuggable interactions.

Locations to update:
โ€ข apps/docs/src/experimental/VariableFontAnimation.stories.tsx (all story exports)

Suggested diff for the Default story:

 export const Default: Story = {
   args: {
     text: 'LAMBDA CURRY',
     fontSize: 80,
     maxInfluenceRadius: 120,
     animationSpeed: 0.1,
     distortionIntensity: 1,
   },
+  play: async ({ canvasElement, step }) => {
+    const canvas = within(canvasElement);
+
+    await step('Initial render', async () => {
+      const textEl = await canvas.findByText('LAMBDA CURRY');
+      expect(textEl).toBeInTheDocument();
+    });
+
+    await step('Hover to trigger animation', async () => {
+      const textEl = canvas.getByText('LAMBDA CURRY');
+      await userEvent.hover(textEl);
+      // TODO: assert CSS custom property changes or style updates
+    });
+
+    await step('Keyboard navigation', async () => {
+      await userEvent.tab();
+      // TODO: assert focus styles or accessibility behavior
+    });
+  },
 };

Apply the same pattern to the other story variants (including Playground) to ensure each has automated interaction and accessibility tests.

๐Ÿ“ Committable suggestion

โ€ผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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!',
},
},
},
};
export const Default: Story = {
args: {
text: 'LAMBDA CURRY',
fontSize: 80,
maxInfluenceRadius: 120,
animationSpeed: 0.1,
distortionIntensity: 1,
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Initial render', async () => {
const textEl = await canvas.findByText('LAMBDA CURRY');
expect(textEl).toBeInTheDocument();
});
await step('Hover to trigger animation', async () => {
const textEl = canvas.getByText('LAMBDA CURRY');
await userEvent.hover(textEl);
// TODO: assert CSS custom property changes or style updates
});
await step('Keyboard navigation', async () => {
await userEvent.tab();
// TODO: assert focus styles or accessibility behavior
});
},
};
๐Ÿค– Prompt for AI Agents
In apps/docs/src/experimental/VariableFontAnimation.stories.tsx from lines 82 to
158, each story export lacks a play function for interaction and accessibility
testing. Add a play function to every story that follows the three-phase
pattern: initial render verification, user interaction simulation, and
accessibility/navigation checks. Use semantic queries like findByText or
getByRole instead of CSS selectors, and organize the steps with the step helper
for clarity and debuggability. Implement this pattern consistently across all
story exports including Default, LargeText, SubtleEffect, IntenseDistortion,
CustomMessage, SmallText, and Playground.


227 changes: 227 additions & 0 deletions apps/docs/src/experimental/VariableFontAnimation.tsx
Original file line number Diff line number Diff line change
@@ -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<VariableFontAnimationProps> = ({
text = 'LAMBDA CURRY',
fontSize = 80,
maxInfluenceRadius = 120,
animationSpeed = 0.1,
distortionIntensity = 1,
className = '',
}) => {
Comment on lines +12 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ› ๏ธ Refactor suggestion

Component structure should follow established patterns.

The component doesn't follow the recommended structure from learnings: external imports, internal imports, type definitions, variants (using CVA), implementation, displayName.

Consider restructuring to use CVA for variants:

+const variableFontVariants = cva(
+  "font-recursive font-normal leading-tight cursor-default select-none flex justify-center items-center min-h-[200px] p-10 rounded-[20px] overflow-hidden relative",
+  {
+    variants: {
+      size: {
+        small: "text-2xl min-h-[150px] p-5",
+        medium: "text-5xl",
+        large: "text-8xl min-h-[250px] p-12",
+      },
+      theme: {
+        default: "bg-gradient-to-br from-indigo-400 to-purple-600",
+        warm: "bg-gradient-to-br from-orange-400 to-red-600",
+        cool: "bg-gradient-to-br from-blue-400 to-cyan-600",
+      }
+    },
+    defaultVariants: {
+      size: "medium",
+      theme: "default",
+    }
+  }
+);

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In apps/docs/src/experimental/VariableFontAnimation.tsx around lines 12 to 19,
the component structure does not follow the recommended pattern of organizing
external imports, internal imports, type definitions, variants using CVA,
implementation, and displayName. Refactor the file to reorder these sections
accordingly and define variants using the CVA utility before the component
implementation to maintain consistency and clarity.

const containerRef = useRef<HTMLDivElement>(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<HTMLSpanElement>;

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) => (
<span
key={index}
className="gooey-letter"
style={{
'--font-weight': '400',
'--font-slant': '0',
'--font-casual': '0',
'--scale': '1',
'--skew-x': '0deg',
'--skew-y': '0deg',
'--intensity': '0',
} as React.CSSProperties}
>
{char === ' ' ? '\u00A0' : char}
</span>
));

return (
<div className={`variable-font-container ${className}`}>
<style>{`
.variable-font-container {
font-family: 'Recursive', monospace;
font-size: ${fontSize}px;
font-weight: 400;
line-height: 1.2;
cursor: default;
user-select: none;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
overflow: hidden;
position: relative;
}

.variable-font-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.1) 0%, transparent 70%);
pointer-events: none;
}

.gooey-letter {
display: inline-block;
font-variation-settings:
'wght' var(--font-weight, 400),
'slnt' var(--font-slant, 0),
'CASL' var(--font-casual, 0);

transform:
scale(var(--scale, 1))
skew(var(--skew-x, 0deg), var(--skew-y, 0deg));

transition:
font-variation-settings ${animationSpeed}s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform ${animationSpeed}s cubic-bezier(0.25, 0.46, 0.45, 0.94);

transform-origin: center center;
color: white;
text-shadow:
0 0 20px rgba(255,255,255,var(--intensity, 0)),
0 0 40px rgba(255,255,255,calc(var(--intensity, 0) * 0.5)),
0 2px 4px rgba(0,0,0,0.3);

filter: blur(calc(var(--intensity, 0) * 0.5px));
}

.gooey-letter:hover {
z-index: 10;
position: relative;
}

/* Loading state */
.variable-font-container:not(.loaded) .gooey-letter {
opacity: 0.7;
}

/* Responsive adjustments */
@media (max-width: 768px) {
.variable-font-container {
font-size: ${fontSize * 0.6}px;
padding: 20px;
min-height: 150px;
}
}
`}</style>

<div className={`gooey-text ${isLoaded ? 'loaded' : ''}`} ref={containerRef}>
{letters}
</div>

{!isLoaded && (
<div style={{
position: 'absolute',
bottom: '10px',
right: '10px',
fontSize: '12px',
color: 'rgba(255,255,255,0.7)',
fontFamily: 'system-ui'
}}>
Loading font...
</div>
)}
</div>
);
};

export default VariableFontAnimation;