# Frieze — Animated UI Component for React

> A calm, always-on brand marquee / logo cloud. Loops horizontally or vertically, reversible, with gradient edge-fade masks, a hairline baseline, an optional 3D-perspective tilt, and per-mark lift-on-hover. Pure CSS at constant velocity, pause-on-hover.

- Page: https://crucibleui.com/components/utility/frieze
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/frieze
```

Registry JSON: https://crucibleui.com/r/frieze.json

Import from `@/components/crucible/frieze`.

## Props (`FriezeProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The marks to display — logos, wordmarks, SVG glyphs, anything. Each direct child becomes one lift-on-hover item. The set is duplicated internally for a seamless loop; only the first copy is exposed to assistive tech. |
| `orientation?` | `"horizontal" \| "vertical"` | `"horizontal"` | Loop axis. |
| `reverse?` | `boolean` | `false` | Reverse the travel direction (right / down instead of left / up). |
| `speed?` | `number` | `40` | Travel speed in pixels per second — held constant regardless of content length or viewport size (duration is derived from the measured track). |
| `gap?` | `number` | `48` | Space between marks, in pixels. |
| `pauseOnHover?` | `boolean` | `true` | Halt the strip while it (or one of its marks) is hovered/focused. |
| `fade?` | `number` | `64` | Width of the gradient edge-fade masks, in pixels, so marks dissolve at the boundary. |
| `repeat?` | `number` | `2` | Copies of the children set laid end to end within one looping half. Bump it when the mark set is too short to fill the viewport. |
| `perspective?` | `boolean` | `false` | Angle the strip in 3D space for a receding logo-wall look. |
| `tilt?` | `number` | `18` | Tilt angle in degrees when `perspective` is on. |
| `restOpacity?` | `number` | `0.55` | Resting opacity of each mark before it lifts to full on hover. |
| `baseline?` | `boolean` | `true` | Draw the hairline architectural baseline that grounds the strip. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Frieze } from "@/components/crucible/frieze";

/** Abstract, brand-free geometric marks paired with placeholder wordmarks. */
function Mark({ glyph, label }: { glyph: React.ReactNode; label: string }) {
  return (
    <span className="flex items-center gap-2.5 whitespace-nowrap px-1 text-neutral-100">
      <span className="text-neutral-300">{glyph}</span>
      <span className="text-lg font-semibold tracking-tight">{label}</span>
    </span>
  );
}

const S = { width: 22, height: 22, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round" as const, strokeLinecap: "round" as const };

const MARKS = [
  { label: "Studio", glyph: <svg {...S}><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" /></svg> },
  { label: "Labs", glyph: <svg {...S}><path d="M12 3 L21 19 L3 19 Z" /></svg> },
  { label: "Works", glyph: <svg {...S}><rect x="4" y="4" width="16" height="16" rx="2" /><path d="M4 12h16" /></svg> },
  { label: "Group", glyph: <svg {...S}><path d="M12 2 L21 7 V17 L12 22 L3 17 V7 Z" /></svg> },
  { label: "Systems", glyph: <svg {...S}><path d="M12 3 L21 12 L12 21 L3 12 Z" /><path d="M12 8 L16 12 L12 16 L8 12 Z" /></svg> },
  { label: "Union", glyph: <svg {...S}><circle cx="9" cy="12" r="6" /><circle cx="15" cy="12" r="6" /></svg> },
];

export default function FriezeDemo() {
  const [tilted, setTilted] = React.useState(false);
  const [reversed, setReversed] = React.useState(false);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-8 overflow-hidden rounded-xl bg-neutral-950 px-6 py-10">
      <div className="text-center">
        <p className="text-sm text-neutral-400">
          Hover a mark to bring it forward. Hover the strip to pause the drift.
        </p>
      </div>

      <div className="w-full max-w-2xl">
        <Frieze
          reverse={reversed}
          perspective={tilted}
          tilt={16}
          speed={36}
          gap={56}
        >
          {MARKS.map((m) => (
            <Mark key={m.label} glyph={m.glyph} label={m.label} />
          ))}
        </Frieze>
      </div>

      <div className="flex flex-wrap items-center justify-center gap-2">
        <Toggle on={tilted} onClick={() => setTilted((v) => !v)}>
          3D tilt
        </Toggle>
        <Toggle on={reversed} onClick={() => setReversed((v) => !v)}>
          Reverse
        </Toggle>
      </div>
    </div>
  );
}

function Toggle({
  on,
  onClick,
  children,
}: {
  on: boolean;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-pressed={on}
      onClick={onClick}
      className={
        "rounded-md border px-3 py-1.5 text-xs font-medium transition-colors " +
        (on
          ? "border-white/25 bg-white/10 text-white"
          : "border-white/10 text-neutral-400 hover:bg-white/5 hover:text-neutral-200")
      }
    >
      {children}
    </button>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`frieze.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export interface FriezeProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /**
   * The marks to display — logos, wordmarks, SVG glyphs, anything. Each direct
   * child becomes one lift-on-hover item. The set is duplicated internally for
   * a seamless loop; only the first copy is exposed to assistive tech.
   */
  children: React.ReactNode;
  /** Loop axis. @default "horizontal" */
  orientation?: "horizontal" | "vertical";
  /** Reverse the travel direction (right / down instead of left / up). @default false */
  reverse?: boolean;
  /**
   * Travel speed in pixels per second — held constant regardless of content
   * length or viewport size (duration is derived from the measured track).
   * @default 40
   */
  speed?: number;
  /** Space between marks, in pixels. @default 48 */
  gap?: number;
  /** Halt the strip while it (or one of its marks) is hovered/focused. @default true */
  pauseOnHover?: boolean;
  /** Width of the gradient edge-fade masks, in pixels, so marks dissolve at the boundary. @default 64 */
  fade?: number;
  /**
   * Copies of the children set laid end to end within one looping half. Bump
   * it when the mark set is too short to fill the viewport. @default 2
   */
  repeat?: number;
  /** Angle the strip in 3D space for a receding logo-wall look. @default false */
  perspective?: boolean;
  /** Tilt angle in degrees when `perspective` is on. @default 18 */
  tilt?: number;
  /** Resting opacity of each mark before it lifts to full on hover. @default 0.55 */
  restOpacity?: number;
  /** Draw the hairline architectural baseline that grounds the strip. @default true */
  baseline?: boolean;
}

const PERSPECTIVE_DISTANCE = 1400;

const STYLES = `
@keyframes crucible-frieze-x { to { transform: translateX(calc(-50% - var(--frieze-gap, 0px) / 2)); } }
@keyframes crucible-frieze-y { to { transform: translateY(calc(-50% - var(--frieze-gap, 0px) / 2)); } }
[data-crucible="frieze"] .frieze-track {
  animation-name: var(--frieze-name);
  animation-duration: var(--frieze-duration, 40s);
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  animation-direction: var(--frieze-dir, normal);
}
[data-crucible="frieze"][data-paused="true"] .frieze-track { animation-play-state: paused; }
[data-crucible="frieze"][data-pause-on-hover="true"]:hover .frieze-track,
[data-crucible="frieze"][data-pause-on-hover="true"]:focus-within .frieze-track { animation-play-state: paused; }
[data-crucible="frieze"] .frieze-item {
  opacity: var(--frieze-rest, 0.55);
  transition: opacity 0.4s ease, transform 0.4s ease, filter 0.4s ease;
}
[data-crucible="frieze"] .frieze-item:hover,
[data-crucible="frieze"] .frieze-item:focus-within {
  opacity: 1;
  transform: scale(1.05);
}
@media (prefers-reduced-motion: reduce) {
  [data-crucible="frieze"] .frieze-track { animation: none !important; }
}
`;

/**
 * Frieze — the calm, always-on brand marquee every landing page needs: a
 * logo cloud that loops horizontally or vertically, reversible, with gradient
 * edge-fade masks so marks dissolve at the boundary and a hairline baseline
 * grounding the strip like an architectural frieze.
 *
 * Crucible signature: marks ride at a hushed reduced opacity and an individual
 * item lifts to full presence on hover (never the whole strip), the loop halts
 * on hover/focus so a mark can be caught, and an optional 3D-perspective tilt
 * angles the whole plane in space. Pure CSS transform loop at a constant
 * pixels-per-second velocity — distinct from a velocity-reactive marquee.
 * Pauses offscreen and on hidden tabs; under reduced motion it freezes to a
 * static, fully-legible wrapped row.
 */
export function Frieze({
  children,
  orientation = "horizontal",
  reverse = false,
  speed = 40,
  gap = 48,
  pauseOnHover = true,
  fade = 64,
  repeat = 2,
  perspective = false,
  tilt = 18,
  restOpacity = 0.55,
  baseline = true,
  className,
  style,
  ...props
}: FriezeProps) {
  const reducedMotion = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const trackRef = React.useRef<HTMLUListElement>(null);
  const [duration, setDuration] = React.useState<number | null>(null);
  const [active, setActive] = React.useState(true);

  const isHorizontal = orientation === "horizontal";
  const items = React.Children.toArray(children);
  const safeRepeat = Math.max(1, Math.floor(repeat));

  // Pause the CSS loop when scrolled out of view or the tab is hidden — a
  // paused animation resumes from the same frame, so there is never a jump.
  React.useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    let inView = true;
    let tabVisible = !document.hidden;
    const update = () => setActive(inView && tabVisible);
    const io = new IntersectionObserver(
      ([entry]) => {
        inView = entry.isIntersecting;
        update();
      },
      { threshold: 0 }
    );
    io.observe(el);
    const onVisibility = () => {
      tabVisible = !document.hidden;
      update();
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  // Derive the loop duration from the measured track so velocity stays constant
  // regardless of how much content is present. Not a rAF loop — the observer is
  // idle until a resize, and tears down on unmount.
  React.useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const measure = () => {
      const total = isHorizontal ? track.scrollWidth : track.scrollHeight;
      // One looping half advances by (total + gap) / 2; time = distance / speed.
      const advance = (total + gap) / 2;
      const next = speed > 0 ? advance / speed : 0;
      setDuration(next > 0 ? next : null);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(track);
    return () => ro.disconnect();
  }, [isHorizontal, gap, speed, safeRepeat, children]);

  // Static, legible frame for reduced-motion users: a wrapped, full-opacity row.
  if (reducedMotion) {
    return (
      <div
        ref={rootRef}
        data-crucible="frieze"
        className={cn("relative", isHorizontal ? "w-full" : "h-full", className)}
        style={style}
        {...props}
      >
        <style>{STYLES}</style>
        <ul
          className="m-0 flex list-none flex-wrap items-center justify-center p-0"
          style={{ gap: `${gap}px` }}
        >
          {items.map((node, i) => (
            <li key={i} className="flex shrink-0 items-center justify-center">
              {node}
            </li>
          ))}
        </ul>
        {baseline && (
          <div
            aria-hidden
            className="pointer-events-none absolute inset-x-0 bottom-0 h-px"
            style={{
              background:
                "linear-gradient(to right, transparent, rgba(255,255,255,0.18), transparent)",
            }}
          />
        )}
      </div>
    );
  }

  const perHalf = items.length * safeRepeat;
  const all = Array.from({ length: perHalf * 2 }, (_, i) => items[i % items.length]);

  const maskImage = isHorizontal
    ? `linear-gradient(to right, transparent 0, #000 ${fade}px, #000 calc(100% - ${fade}px), transparent 100%)`
    : `linear-gradient(to bottom, transparent 0, #000 ${fade}px, #000 calc(100% - ${fade}px), transparent 100%)`;

  const viewportTransform = perspective
    ? isHorizontal
      ? `rotateY(${tilt}deg)`
      : `rotateX(${tilt}deg)`
    : undefined;

  const trackStyle = {
    "--frieze-name": isHorizontal ? "crucible-frieze-x" : "crucible-frieze-y",
    "--frieze-dir": reverse ? "reverse" : "normal",
    "--frieze-gap": `${gap}px`,
    "--frieze-rest": String(restOpacity),
    ...(duration ? { "--frieze-duration": `${duration}s` } : null),
    gap: `${gap}px`,
  } as React.CSSProperties;

  // The edge-fade mask lives on the static viewport, not the moving track — a
  // mask on the track would travel with it and fade the track's own ends
  // instead of the strip's boundary.
  const viewportStyle: React.CSSProperties = {
    maskImage,
    WebkitMaskImage: maskImage,
    ...(viewportTransform ? { transform: viewportTransform } : null),
  };

  return (
    <div
      ref={rootRef}
      data-crucible="frieze"
      data-paused={!active}
      data-pause-on-hover={pauseOnHover}
      className={cn("relative", isHorizontal ? "w-full" : "h-full", className)}
      style={perspective ? { perspective: `${PERSPECTIVE_DISTANCE}px`, ...style } : style}
      {...props}
    >
      <style>{STYLES}</style>
      <div
        className={cn(
          "relative overflow-hidden",
          isHorizontal ? "w-full" : "h-full"
        )}
        style={viewportStyle}
      >
        <ul
          ref={trackRef}
          className={cn(
            "frieze-track m-0 flex list-none p-0 will-change-transform",
            isHorizontal ? "w-max flex-row items-center" : "h-max flex-col items-center"
          )}
          style={trackStyle}
        >
          {all.map((node, i) => (
            <li
              key={i}
              aria-hidden={i >= items.length || undefined}
              // Loop copies are presentation only: keep any links inside them
              // out of the tab order as well as the accessibility tree.
              inert={i >= items.length || undefined}
              className="frieze-item flex shrink-0 items-center justify-center"
            >
              {node}
            </li>
          ))}
        </ul>
        {baseline && (
          <div
            aria-hidden
            className={cn(
              "pointer-events-none absolute",
              isHorizontal ? "inset-x-0 bottom-0 h-px" : "inset-y-0 left-0 w-px"
            )}
            style={{
              background: isHorizontal
                ? "linear-gradient(to right, transparent, rgba(255,255,255,0.18), transparent)"
                : "linear-gradient(to bottom, transparent, rgba(255,255,255,0.18), transparent)",
            }}
          />
        )}
      </div>
    </div>
  );
}
```
