Frieze
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.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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 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`,
maskImage,
WebkitMaskImage: maskImage,
} as React.CSSProperties;
const viewportTransform = perspective
? isHorizontal
? `rotateY(${tilt}deg)`
: `rotateX(${tilt}deg)`
: undefined;
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={viewportTransform ? { transform: viewportTransform } : undefined}
>
<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}
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/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 all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.