UIReveals and Continuous Motionmotion/react

RotatingIcons

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 props
2200
0.6
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add rotating-icons

Writes 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.

example.tsx
1import { RotatingIcons } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <RotatingIcons />6  );7}

Props

PropTypeDefault
prefix

Static text before the icon (empty hides it).

string'built for'
icons

Built-in icons: scroll, shader, interface, sparkles, cursor, layers, code, palette, zap, globe.

RotatingIconName[]['scroll', 'shader', 'interface']
labels

Spoken label per icon for screen readers.

string[]per-icon defaults
interval

Milliseconds each icon stays visible.

number2200
duration

Flip duration in seconds.

number0.6
direction

Direction the cube rolls.

'up' | 'down''up'
tile

Render the icon inside a rounded tile.

booleantrue
iconColor

Icon stroke color.

string'#fafafa'
tileColor

Tile background color.

string'#1c1c1f'
color

Prefix text color (defaults to the theme foreground).

stringundefined
fontSize

Prefix font size in px; the icon scales with it.

number56
as

Element rendered for the line.

'h1' | 'h2' | 'h3' | 'p' | 'span''p'
className

Merged onto the root.

string''
style

Merged onto the root.

React.CSSPropertiesundefined

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

components/ui/rotating-icons.tsx
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}