FLOWDocs
Components

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.

View source on GitHub →

Usage:

import { CopyButton } from "@flow-industries/ui/components/animated-icons";

<CopyButton value="bun add @flow-industries/ui">
  bun add @flow-industries/ui
</CopyButton>

Source:

components/ui/animated-icons.tsx
"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.

View source on GitHub →

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:

components/ui/color-swatch.tsx
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.

View source on GitHub →

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:

components/ui/dissolve-filter.tsx
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.

View source on GitHub →

Usage:

import { TimeElapsed, TimeSince } from "@flow-industries/ui/components/time";

<TimeElapsed date={post.createdAt} />
<TimeSince date="2024-01-15" format="MMM yyyy" />

Source:

components/ui/time.tsx
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.

View source on GitHub →

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:

components/ui/word-flash.tsx
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>
  );
}

On this page