A headline accent that rolls through icons on a 3D cube flip instead of words — a static prefix followed by a tiled icon that turns over on an interval.
Preview
built for scroll, shaders, interfaces
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add rotating-iconsWrites components/ui/rotating-icons.tsx and components/styles/rotating-icons.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { RotatingIcons } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <RotatingIcons />6 );7}Props
Accessibility
The animated icons are aria-hidden; a visually hidden sentence (prefix plus every icon's spoken label) gives screen readers the full meaning once instead of announcing each flip. The loop pauses when off-screen or the tab is hidden, and reduced motion shows the first icon without flipping.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/rotating-icons.module.css";6 7export type RotatingIconName = "scroll" | "shader" | "interface" | "sparkles" | "cursor" | "layers" | "code" | "palette" | "zap" | "globe";8 9export interface RotatingIconsProps {10 /** Static text before the rotating icon (empty hides it) */11 prefix?: string;12 /** Icons to cycle through, in order */13 icons?: RotatingIconName[];14 /** Spoken label per icon, in the same order (defaults to the icon names) */15 labels?: string[];16 /** Milliseconds each icon stays visible */17 interval?: number;18 /** Flip duration in seconds */19 duration?: number;20 /** Direction the cube rolls */21 direction?: "up" | "down";22 /** Render each icon inside a rounded tile */23 tile?: boolean;24 /** Icon stroke color */25 iconColor?: string;26 /** Tile background color (when tile is on) */27 tileColor?: string;28 /** Prefix text color (defaults to the theme foreground) */29 color?: string;30 /** Font size of the prefix in px — the icon scales with it */31 fontSize?: number;32 /** Element rendered for the whole line */33 as?: "h1" | "h2" | "h3" | "p" | "span";34 /** Optional className merged onto the root */35 className?: string;36 /** Optional inline styles merged onto the root */37 style?: React.CSSProperties;38}39 40const ICON_PATHS: Record<RotatingIconName, string[]> = {41 scroll: ["M12 2a6 6 0 0 1 6 6v8a6 6 0 0 1-12 0V8a6 6 0 0 1 6-6z", "M12 6v4", "M9 21l3 2 3-2"],42 shader: ["M12 3a9 9 0 1 0 9 9", "M12 3a9 9 0 0 1 9 9", "M7.5 12a4.5 4.5 0 0 1 9 0", "M12 7.5a4.5 4.5 0 0 0 0 9"],43 interface: ["M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", "M3 9h18", "M9 21V9"],44 sparkles: ["M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z", "M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"],45 cursor: ["M4 3l7 17 2.5-7.5L21 10z", "M13.5 12.5L20 19"],46 layers: ["M12 3l9 5-9 5-9-5z", "M3 13l9 5 9-5", "M3 17.5l9 5 9-5"],47 code: ["M8 7l-5 5 5 5", "M16 7l5 5-5 5", "M14 4l-4 16"],48 palette: ["M12 3a9 9 0 1 0 0 18c1.1 0 1.6-.8 1.6-1.7 0-1.5-1.2-1.8-1.2-3 0-1 .8-1.6 1.8-1.6H17a4 4 0 0 0 4-4C21 6.6 17 3 12 3z", "M7.5 11.5h.01", "M10 7.5h.01", "M15 7.5h.01"],49 zap: ["M13 2L4 14h7l-1 8 9-12h-7z"],50 globe: ["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z", "M3 12h18", "M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"],51};52 53const DEFAULT_ICONS: RotatingIconName[] = ["scroll", "shader", "interface"];54const DEFAULT_LABELS: Record<RotatingIconName, string> = {55 scroll: "scroll",56 shader: "shaders",57 interface: "interfaces",58 sparkles: "delight",59 cursor: "interaction",60 layers: "depth",61 code: "developers",62 palette: "design",63 zap: "speed",64 globe: "the web",65};66 67function Glyph({ name }: { name: RotatingIconName }) {68 return (69 <svg className={styles.glyph} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">70 {ICON_PATHS[name].map((d) => (71 <path key={d} d={d} />72 ))}73 </svg>74 );75}76 77export function RotatingIcons({78 prefix = "built for",79 icons = DEFAULT_ICONS,80 labels,81 interval = 2200,82 duration = 0.6,83 direction = "up",84 tile = true,85 iconColor = "#fafafa",86 tileColor = "#1c1c1f",87 color,88 fontSize = 56,89 as: Tag = "p",90 className = "",91 style,92}: RotatingIconsProps) {93 const reduceMotion = useReducedMotion();94 const rootRef = React.useRef<HTMLElement>(null);95 const list = icons.length > 0 ? icons : DEFAULT_ICONS;96 const spoken = list.map((icon, i) => labels?.[i] ?? DEFAULT_LABELS[icon] ?? icon);97 const [index, setIndex] = React.useState(0);98 const [visible, setVisible] = React.useState(true);99 100 // Pause the loop when off-screen or when the tab is hidden.101 React.useEffect(() => {102 const el = rootRef.current;103 if (!el || typeof IntersectionObserver === "undefined") return;104 const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting && document.visibilityState === "visible"));105 io.observe(el);106 const onVis = () => setVisible(document.visibilityState === "visible");107 document.addEventListener("visibilitychange", onVis);108 return () => {109 io.disconnect();110 document.removeEventListener("visibilitychange", onVis);111 };112 }, []);113 114 React.useEffect(() => {115 if (reduceMotion || !visible || list.length < 2) return;116 const id = window.setInterval(() => setIndex((i) => (i + 1) % list.length), Math.max(600, interval));117 return () => window.clearInterval(id);118 }, [reduceMotion, visible, list.length, interval]);119 120 const current = list[index % list.length];121 const sign = direction === "up" ? 1 : -1;122 const sentence = `${prefix ? `${prefix} ` : ""}${spoken.join(", ")}`;123 const ElementTag = Tag as React.ElementType;124 125 return (126 <ElementTag127 ref={rootRef}128 className={`${styles.root} ${className}`.trim()}129 style={130 {131 "--mf-ri-size": `${fontSize}px`,132 "--mf-ri-icon": iconColor,133 "--mf-ri-tile": tileColor,134 ...(color ? { color } : null),135 ...style,136 } as React.CSSProperties137 }138 >139 <span className={styles.srOnly}>{sentence}</span>140 <span className={styles.visual} aria-hidden="true">141 {prefix && <span className={styles.prefix}>{prefix}</span>}142 <span className={`${styles.slot} ${tile ? styles.tile : ""}`.trim()}>143 <AnimatePresence initial={false} mode="popLayout">144 <motion.span145 key={`${index}-${current}`}146 className={styles.face}147 initial={reduceMotion ? false : { rotateX: -90 * sign, y: `${45 * sign}%`, opacity: 0 }}148 animate={{ rotateX: 0, y: "0%", opacity: 1 }}149 exit={reduceMotion ? { opacity: 0 } : { rotateX: 90 * sign, y: `${-45 * sign}%`, opacity: 0 }}150 transition={reduceMotion ? { duration: 0 } : { duration, ease: [0.22, 1, 0.36, 1] }}151 >152 <Glyph name={current} />153 </motion.span>154 </AnimatePresence>155 </span>156 </span>157 </ElementTag>158 );159}