Installation
Copy and paste the following code into your project.
"use client";
import React, { useRef, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
export interface IconTextSubstitutionProps
extends React.HTMLAttributes<HTMLDivElement> {
text: string;
substitutions: {
word: string;
icon: React.ReactNode;
animationStyle?: "bounce" | "pulse" | "rotate" | "shake" | "none";
backgroundStyle?: "solid" | "gradient" | "glow" | "none";
backgroundColor?: string;
scale?: number;
verticalOffset?: number;
}[];
textColor?: string;
fontSize?: string;
fontWeight?: string;
enableHoverEffects?: boolean;
enableAnimations?: boolean;
animationSpeed?: number;
inline?: boolean;
}
export function IconTextSubstitution({
text,
substitutions = [],
textColor = "text-gray-200",
fontSize = "text-2xl",
fontWeight = "font-semibold",
enableHoverEffects = true,
enableAnimations = true,
animationSpeed = 1,
inline = false,
className,
...props
}: IconTextSubstitutionProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [segments, setSegments] = useState<
Array<{
type: "text" | "icon";
content: string | number;
substitution?: any;
}>
>([]);
useEffect(() => {
const processedSegments: Array<{
type: "text" | "icon";
content: string | number;
substitution?: any;
}> = [];
let remainingText = text;
let lastIndex = 0;
const sortedSubstitutions = [...substitutions].sort((a, b) => {
const indexA = text.toLowerCase().indexOf(a.word.toLowerCase());
const indexB = text.toLowerCase().indexOf(b.word.toLowerCase());
return indexA - indexB;
});
sortedSubstitutions.forEach((sub) => {
const wordIndex = remainingText
.toLowerCase()
.indexOf(sub.word.toLowerCase());
if (wordIndex !== -1) {
if (wordIndex > 0) {
processedSegments.push({
type: "text",
content: remainingText.substring(0, wordIndex),
});
}
processedSegments.push({
type: "icon",
content: processedSegments.length,
substitution: sub,
});
remainingText = remainingText.substring(wordIndex + sub.word.length);
}
});
if (remainingText) {
processedSegments.push({
type: "text",
content: remainingText,
});
}
setSegments(processedSegments);
}, [text, substitutions]);
const getAnimationVariant = (style: string = "none", speed: number = 1) => {
const duration = 2 / speed;
switch (style) {
case "bounce":
return {
animate: {
y: ["-5%", "0%", "-5%"],
transition: {
duration,
repeat: Infinity,
ease: "easeInOut",
},
},
};
case "pulse":
return {
animate: {
scale: [1, 1.05, 1],
opacity: [1, 0.8, 1],
transition: {
duration,
repeat: Infinity,
ease: "easeInOut",
},
},
};
case "rotate":
return {
animate: {
rotate: [0, 10, 0, -10, 0],
transition: {
duration,
repeat: Infinity,
ease: "easeInOut",
},
},
};
case "shake":
return {
animate: {
x: [0, 3, -3, 3, 0],
transition: {
duration: duration / 2,
repeat: Infinity,
ease: "easeInOut",
},
},
};
default:
return {};
}
};
const getBackgroundStyle = (style: string = "none", color?: string) => {
switch (style) {
case "solid":
return {
backgroundColor: color || "rgba(255, 255, 255, 0.1)",
borderRadius: "0.5rem",
padding: "0.25rem 0.5rem",
};
case "gradient":
return {
background: color
? `linear-gradient(135deg, ${color}, rgba(0,0,0,0.3))`
: "linear-gradient(135deg, rgba(255,255,255,0.2), rgba(0,0,0,0.1))",
borderRadius: "0.5rem",
padding: "0.25rem 0.5rem",
};
case "glow":
return {
backgroundColor: "transparent",
borderRadius: "0.5rem",
padding: "0.25rem 0.5rem",
boxShadow: color
? `0 0 10px ${color}`
: "0 0 10px rgba(255, 255, 255, 0.5)",
};
default:
return {};
}
};
return (
<div
ref={containerRef}
className={cn(
inline ? "inline-flex items-center" : "flex flex-wrap items-center",
inline ? "gap-0" : "gap-1",
textColor,
fontSize,
fontWeight,
className,
)}
{...props}
>
<AnimatePresence>
{segments.map((segment, index) => {
if (segment.type === "text") {
return (
<span key={`text-${index}`} className="inline-block">
{segment.content}
</span>
);
} else {
const {
icon,
animationStyle,
backgroundStyle,
backgroundColor,
scale = 1,
verticalOffset = 0,
} = segment.substitution;
const animationVariant = getAnimationVariant(
animationStyle,
animationSpeed,
);
const bgStyle = getBackgroundStyle(
backgroundStyle,
backgroundColor,
);
return (
<motion.div
key={`icon-${segment.content}`}
className={cn(
"inline-flex items-center justify-center",
enableHoverEffects && "transition-transform hover:scale-110",
)}
style={{
...bgStyle,
transform: `scale(${scale})`,
position: "relative",
top: `${verticalOffset}px`,
margin: inline ? "0 1px" : undefined,
}}
whileHover={enableHoverEffects ? { scale: scale * 1.1 } : {}}
{...(enableAnimations && animationStyle !== "none"
? animationVariant
: {})}
>
{icon}
</motion.div>
);
}
})}
</AnimatePresence>
</div>
);
}
Update the import paths to match your project setup.
import IconTextSubstitution from "@/components/ui/icon-text-substitution";
Usage
import IconTextSubstitution from "@/components/ui/icon-text-substitution";
export default function Example() {
return (
<IconTextSubstitution
text="Travel to your favorite places this summer!"
substitutions={[
{
word: "Travel",
icon: <img src="/images/passport.png" alt="Passport" />,
animationStyle: "pulse",
},
{
word: "summer",
icon: <img src="/images/sun.png" alt="Sun" />,
animationStyle: "rotate",
},
]}
/>
);
}
Props
Prop | Type | Description | Default |
---|---|---|---|
text | string | Text content to be displayed with icon substitutions | Required |
substitutions | SubstitutionItem[] | Array of substitution objects defining replacements | [] |
textColor | string | Text color for non-substituted text | text-gray-200 |
fontSize | string | Font size for the text (CSS class) | text-2xl |
fontWeight | string | Font weight for the text (CSS class) | font-semibold |
enableHoverEffects | boolean | Whether to enable hover effects on icons | true |
enableAnimations | boolean | Whether to enable animations for icons | true |
animationSpeed | number | Animation speed multiplier (1 = normal speed) | 1 |
inline | boolean | Whether to use inline style for text flow | false |
SubstitutionItem Properties
Property | Type | Description | Default |
---|---|---|---|
word | string | The word or phrase to be replaced | Required |
icon | React.ReactNode | The icon element to substitute | Required |
animationStyle | "bounce" | "pulse" | "rotate" | "shake" | "none" | Animation style for the icon | "none" |
backgroundStyle | "solid" | "gradient" | "glow" | "none" | Background style for the icon | "none" |
backgroundColor | string | Custom background color (CSS color value) | - |
scale | number | Scale factor for the icon (1 = normal size) | 1 |
verticalOffset | number | Vertical alignment adjustment (in pixels) | 0 |
Examples
Text with Icon Substitutions
<div className="flex flex-col items-center justify-center space-y-8">
<IconTextSubstitution
text="Travel to your ❤️ places, this ☀️, book your ➡️ now!"
fontSize="text-3xl md:text-4xl"
fontWeight="font-bold"
textColor="text-gray-300"
enableHoverEffects={true}
enableAnimations={true}
inline={true}
substitutions={[
{
word: "❤️",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-8 w-8 text-red-500"
>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
),
animationStyle: "pulse",
backgroundStyle: "none",
verticalOffset: -2,
},
{
word: "☀️",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-10 w-10 text-yellow-400"
>
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
</svg>
),
animationStyle: "rotate",
backgroundStyle: "none",
verticalOffset: -2,
},
{
word: "➡️",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-10 w-10 text-sky-400"
>
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
</svg>
),
animationStyle: "shake",
backgroundStyle: "none",
verticalOffset: -2,
},
]}
/>
</div>
Feature List with Icons
<div className="flex flex-col items-center justify-center space-y-8">
<div className="w-full max-w-2xl space-y-4">
<IconTextSubstitution
text="Secure cloud storage for all your files"
fontSize="text-xl"
fontWeight="font-medium"
textColor="text-gray-300"
enableHoverEffects={true}
enableAnimations={true}
inline={true}
substitutions={[
{
word: "Secure",
icon: (
<span className="mr-1 inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="mr-1 h-5 w-5 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span className="font-medium text-green-400">Secure</span>
</span>
),
animationStyle: "pulse",
backgroundStyle: "none",
},
{
word: "cloud",
icon: (
<span className="mx-1 inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="mr-1 h-5 w-5 text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
<span className="font-medium text-blue-400">cloud</span>
</span>
),
animationStyle: "pulse",
backgroundStyle: "none",
verticalOffset: -1,
},
]}
/>
</div>
</div>