Effects
AnimatedIcons, ColorSwatch, DissolveFilter, Time, WordFlash.
Decorative and motion primitives used across Flow sites.
AnimatedIcons
Copy buttons with an animated check/copy icon morph. Exports: CopyCheckIcon, CopyButton.
Usage:
import { CopyButton } from "@flow-industries/ui/components/animated-icons";
<CopyButton value="bun add @flow-industries/ui">
bun add @flow-industries/ui
</CopyButton>Source:
"use client";
import { Check, Copy } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useRef, useState } from "react";
import { cn } from "../../utils/cn";
function CopyCheckIcon({
copied,
className,
size = 14,
}: {
copied: boolean;
className?: string;
size?: number;
}) {
const shouldReduceMotion = useReducedMotion();
const noMotion = !!shouldReduceMotion;
return (
<div
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
initial={{ scale: noMotion ? 1 : 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: noMotion ? 1 : 0.5, opacity: 0 }}
transition={
noMotion
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0.2 }
}
className="absolute inset-0 flex items-center justify-center"
>
<Check
style={{ width: size, height: size }}
className="text-foreground stroke-[2.5]"
/>
</motion.div>
) : (
<motion.div
key="copy"
initial={{ scale: noMotion ? 1 : 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: noMotion ? 1 : 0.5, opacity: 0 }}
transition={
noMotion
? { duration: 0 }
: { duration: 0.1, ease: [0.25, 0.1, 0.25, 1] }
}
className="absolute inset-0 flex items-center justify-center"
>
<Copy
style={{ width: size, height: size }}
className="text-muted-foreground stroke-[2.5]"
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function CopyButton({
value,
className,
children,
}: {
value: string;
className?: string;
children: React.ReactNode;
}) {
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const copy = useCallback(() => {
navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), 1500);
}, [value]);
return (
<button
type="button"
data-slot="copy-button"
onClick={copy}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs font-mono transition-[color,background-color] duration-100 ease-(--ease-out)",
copied ? "bg-muted" : "bg-secondary hover:bg-muted",
className,
)}
>
<span className="flex-1 text-left">{children}</span>
<CopyCheckIcon
copied={copied}
className={cn(
"transition-opacity duration-100",
!copied && !hovered && "opacity-0",
)}
size={12}
/>
</button>
);
}
export { CopyButton, CopyCheckIcon };ColorSwatch
Solid swatches and three-shade hue groups for palette documentation. Exports: ColorSwatch, HueGroup. Sizes: sm, default, lg.
Usage:
import { ColorSwatch, HueGroup } from "@flow-industries/ui/components/color-swatch";
<ColorSwatch color="bg-brand" />
<HueGroup dark="bg-pink/70" standard="bg-pink" light="bg-pink/30" />Source:
import { cn } from "../../utils/cn";
const sizes = {
sm: {
swatch: "w-8 h-8",
fg: "w-4 h-4",
rounded: "rounded-md",
roundedInner: "rounded-[2px]",
gap: "gap-px",
},
default: {
swatch: "w-12 h-12",
fg: "w-6 h-6",
rounded: "rounded-lg",
roundedInner: "rounded-xs",
gap: "gap-0.5",
},
lg: {
swatch: "w-16 h-16",
fg: "w-8 h-8",
rounded: "rounded-xl",
roundedInner: "rounded-sm",
gap: "gap-0.5",
},
} as const;
type Size = "sm" | "default" | "lg";
function ColorSwatch({
color,
className,
size = "default",
border,
...props
}: React.ComponentProps<"div"> & {
color: string;
size?: Size;
border?: boolean;
}) {
const s = sizes[size];
return (
<div
data-slot="color-swatch"
className={cn(
s.swatch,
s.rounded,
color,
border && "border border-secondary",
className,
)}
{...props}
/>
);
}
function HueGroup({
dark,
standard,
light,
size = "default",
className,
}: {
dark: string;
standard: string;
light: string;
size?: Size;
className?: string;
}) {
const s = sizes[size];
const roundedL = `${s.rounded.replace("rounded", "rounded-l")} ${s.roundedInner.replace("rounded", "rounded-r")}`;
const roundedR = `${s.rounded.replace("rounded", "rounded-r")} ${s.roundedInner.replace("rounded", "rounded-l")}`;
return (
<div data-slot="hue-group" className={cn("flex", s.gap, className)}>
<div className={cn(s.swatch, roundedL, dark)} />
<div className={cn(s.swatch, s.roundedInner, standard)} />
<div className={cn(s.swatch, roundedR, light)} />
</div>
);
}
export { ColorSwatch, HueGroup };DissolveFilter
An SVG <filter> — turbulence + displacement + blur — that animates once. Reference it by filterId on any element's style={{ filter: "url(#myFilter)" }} to dissolve it.
Usage:
import { DissolveFilter } from "@flow-industries/ui/components/dissolve-filter";
<>
<DissolveFilter filterId="dissolve-hero" duration="1.2s" />
<h1 style={{ filter: "url(#dissolve-hero)" }}>Ephemeral</h1>
</>Source:
interface DissolveFilterProps {
filterId: string;
seed?: number;
duration?: string;
}
function DissolveFilter({
filterId,
seed,
duration = "1s",
}: DissolveFilterProps) {
const resolvedSeed = seed ?? Math.floor(Math.random() * 10000);
return (
<svg
className="absolute h-0 w-0"
data-slot="dissolve-filter"
aria-hidden="true"
>
<defs>
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
<feTurbulence
type="turbulence"
baseFrequency="0.08"
numOctaves="4"
result="noise"
seed={resolvedSeed}
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="0"
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
>
<animate
attributeName="scale"
values="0;25;80;150"
dur={duration}
fill="freeze"
/>
</feDisplacementMap>
<feGaussianBlur in="displaced" stdDeviation="0">
<animate
attributeName="stdDeviation"
values="0;0.5;2;5"
dur={duration}
fill="freeze"
/>
</feGaussianBlur>
</filter>
</defs>
</svg>
);
}
export { DissolveFilter, type DissolveFilterProps };Time
Relative-time components. TimeElapsed shows "just now" / "5m" / "3h" / "2d" and live-updates within the first hour; falls back to a formatted date after three weeks. TimeSince always renders a formatted date. Both render a semantic <time> element with a machine-readable dateTime, and carry the full absolute timestamp — on hover via title and to screen readers — so the exact moment is never lost behind the relative label.
Usage:
import { TimeElapsed, TimeSince } from "@flow-industries/ui/components/time";
<TimeElapsed date={post.createdAt} />
<TimeSince date="2024-01-15" format="MMM yyyy" />Source:
import { format } from "date-fns";
import type * as React from "react";
import { useEffect, useState } from "react";
function toDate(input: Date | string | number): Date {
return input instanceof Date ? input : new Date(input);
}
function getTimeAgo(date: Date): string {
const diffInSeconds = Math.floor((Date.now() - date.getTime()) / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
const diffInWeeks = Math.floor(diffInDays / 7);
const diffInMonths = Math.floor(diffInDays / 30);
const diffInYears = Math.floor(diffInDays / 365);
if (diffInSeconds < 60) return "just now";
if (diffInMinutes < 60) return `${diffInMinutes}m`;
if (diffInHours < 24) return `${diffInHours}h`;
if (diffInDays < 7) return `${diffInDays}d`;
if (diffInWeeks < 3) return `${diffInWeeks}w`;
if (diffInMonths < 12) return `${diffInMonths}mo`;
return `${diffInYears}y`;
}
interface TimeElapsedProps extends React.ComponentProps<"time"> {
date: Date | string | number;
longFormat?: string;
}
function TimeElapsed({
date,
longFormat = "MMM d",
className,
ref,
...props
}: TimeElapsedProps) {
const dateObj = toDate(date);
const [label, setLabel] = useState(() => getTimeAgo(dateObj));
useEffect(() => {
const update = () => setLabel(getTimeAgo(dateObj));
window.addEventListener("focus", update);
const diffInHours = (Date.now() - dateObj.getTime()) / (1000 * 60 * 60);
let interval: ReturnType<typeof setInterval> | undefined;
if (diffInHours <= 1) {
interval = setInterval(update, 60_000);
}
return () => {
window.removeEventListener("focus", update);
if (interval) clearInterval(interval);
};
}, [dateObj]);
const diffInWeeks =
(Date.now() - dateObj.getTime()) / (1000 * 60 * 60 * 24 * 7);
const display = diffInWeeks > 3 ? format(dateObj, longFormat) : label;
const absoluteLabel = format(dateObj, "PPPPpppp");
return (
<time
ref={ref}
data-slot="time-elapsed"
className={className}
dateTime={dateObj.toISOString()}
title={absoluteLabel}
{...props}
>
<span aria-hidden="true">{display}</span>
<span className="sr-only">{absoluteLabel}</span>
</time>
);
}
interface TimeSinceProps extends React.ComponentProps<"time"> {
date: Date | string | number;
format?: string;
}
function TimeSince({
date,
format: fmt = "MMM yyyy",
className,
ref,
...props
}: TimeSinceProps) {
const dateObj = toDate(date);
const absoluteLabel = format(dateObj, "PPPPpppp");
return (
<time
ref={ref}
data-slot="time-since"
className={className}
dateTime={dateObj.toISOString()}
title={absoluteLabel}
{...props}
>
<span aria-hidden="true">{format(dateObj, fmt)}</span>
<span className="sr-only">{absoluteLabel}</span>
</time>
);
}
export { TimeElapsed, type TimeElapsedProps, TimeSince, type TimeSinceProps };WordFlash
Hover-triggered word-by-word reveal that animates each word with a blur-in/blur-out transition, then crossfades to full content. Respects prefers-reduced-motion.
Usage:
import { WordFlash } from "@flow-industries/ui/components/word-flash";
const [hovered, setHovered] = useState(false);
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
<WordFlash
isHovered={hovered}
text="SHIP FAST AND OFTEN"
title={<h2>Ship often</h2>}
description={<p>Speed compounds.</p>}
/>
</div>Source:
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
interface WordFlashProps {
text: string;
title: React.ReactNode;
description: React.ReactNode;
className?: string;
wordDuration?: number;
isHovered: boolean;
}
export function WordFlash({
text,
title,
description,
className,
wordDuration = 100,
isHovered,
}: WordFlashProps) {
const shouldReduceMotion = useReducedMotion();
const words = text.split(" ");
const [currentWordIndex, setCurrentWordIndex] = useState(-1);
const [isAnimating, setIsAnimating] = useState(false);
const [hasCompleted, setHasCompleted] = useState(false);
const [wordMounted, setWordMounted] = useState(false);
const hasPlayedRef = useRef(false);
useEffect(() => {
if (isHovered && !isAnimating && !hasPlayedRef.current) {
hasPlayedRef.current = true;
setIsAnimating(true);
setCurrentWordIndex(0);
}
if (!isHovered) {
hasPlayedRef.current = false;
setIsAnimating(false);
setCurrentWordIndex(-1);
setWordMounted(false);
}
}, [isHovered, isAnimating]);
// Progress through words only after word is mounted
useEffect(() => {
if (
wordMounted &&
isAnimating &&
currentWordIndex >= 0 &&
currentWordIndex < words.length
) {
const timer = setTimeout(() => {
setWordMounted(false);
setCurrentWordIndex((prev) => prev + 1);
}, wordDuration);
return () => clearTimeout(timer);
} else if (isAnimating && currentWordIndex >= words.length) {
setIsAnimating(false);
setCurrentWordIndex(-1);
setHasCompleted(true);
}
}, [wordMounted, currentWordIndex, words.length, wordDuration, isAnimating]);
const showWords =
isAnimating && currentWordIndex >= 0 && currentWordIndex < words.length;
return (
<div className={className}>
<AnimatePresence mode="wait">
{showWords ? (
<motion.span
key={currentWordIndex}
initial={{
opacity: 0,
filter: shouldReduceMotion ? "blur(0px)" : "blur(8px)",
}}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
filter: shouldReduceMotion ? "blur(0px)" : "blur(8px)",
}}
transition={{
duration: shouldReduceMotion ? 0 : 0.08,
ease: "easeOut",
}}
onAnimationComplete={() => setWordMounted(true)}
className="text-6xl tracking-wide leading-none uppercase scale-y-90 font-base block"
>
{words[currentWordIndex]}
</motion.span>
) : (
<motion.div
key={hasCompleted ? "content-full" : "content-title"}
initial={{
opacity: 0,
filter: shouldReduceMotion ? "blur(0px)" : "blur(12px)",
}}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
filter: shouldReduceMotion ? "blur(0px)" : "blur(8px)",
}}
transition={{
duration: shouldReduceMotion ? 0 : 0.3,
ease: [0.25, 0.1, 0.25, 1],
}}
>
{title}
{hasCompleted && description}
</motion.div>
)}
</AnimatePresence>
</div>
);
}