primitives
Glass Fan
A row of frosted-glass cards fanned out like a hand of cards. Each card sits at a fixed resting rotation and overlaps its neighbors via negative margins, behind a 10px backdrop blur with a faint top-down sheen. On container hover the whole fan straightens to 0° and spreads apart in one CSS transition (no per-frame JS) — link cards do the same on keyboard focus. Data-driven via a typed items array (icon, label, optional rotation/href); an even fan angle is derived from each card's index when no explicit rotation is given. The label strip is painted from a data-text attribute and all visible color comes from tokens, so it themes correctly in light and dark.
Ported from codebykay101 (MIT)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/glass-fanUsage
<GlassFan items={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | GlassFanItem[] | — | The cards to fan out, left to right. |
| spread | number | 40 | Total angular spread (in degrees) used to derive each card's resting rotation when an item omits its own `rotate`. Default 40 → outermost cards sit at ±20°. |
| cardWidth | number | 180 | Card width in px (default 180). |
| cardHeight | number | 200 | Card height in px (default 200). |
| className | string | Additional class names merged onto the root element. | |
| as | React.ElementType | — | Polymorphic root element (default "div"). Use "ul"/"nav" for semantics. |
Source
"use client";
import { createElement, useEffect, useState } from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./glass-fan.module.css";
/** A single frosted-glass card in the fan. */
export type GlassFanItem = {
/** Decorative icon (e.g. an inline <svg> or icon component). Rendered aria-hidden; the label carries meaning. */
icon: React.ReactNode;
/** Short label shown on the card's bottom strip (also used as the accessible name). */
label: string;
/**
* Resting rotation of the card in degrees. The fan "spreads" by giving the
* left cards negative angles and the right cards positive ones. If omitted,
* an even fan is derived from the card's index across the set.
*/
rotate?: number;
/** Optional href — when present the card renders as an <a> and is keyboard-focusable. */
href?: string;
};
export type GlassFanProps = {
/** The cards to fan out, left to right. */
items: GlassFanItem[];
/**
* Total angular spread (in degrees) used to derive each card's resting
* rotation when an item omits its own `rotate`. Default 40 → outermost
* cards sit at ±20°.
*/
spread?: number;
/** Card width in px (default 180). */
cardWidth?: number;
/** Card height in px (default 200). */
cardHeight?: number;
/** Additional class names merged onto the root element. */
className?: string;
/** Polymorphic root element (default "div"). Use "ul"/"nav" for semantics. */
as?: React.ElementType;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;
/**
* Derive an even fan angle for a card at `index` of `count` when no explicit
* rotation is supplied. Single card sits flat; otherwise angles run linearly
* from -spread/2 to +spread/2.
*/
function fanAngle(index: number, count: number, spread: number): number {
if (count <= 1) return 0;
const step = spread / (count - 1);
return -spread / 2 + step * index;
}
export function GlassFan({
items,
spread = 40,
cardWidth = 180,
cardHeight = 200,
className = "",
as,
style: consumerStyle,
...props
}: GlassFanProps) {
const Root = (as ?? "div") as React.ElementType;
// Mount-gate + null-safe reduced-motion check (contract pattern).
// useReducedMotion() is null on SSR but a boolean on first client render, so
// we keep server/first-client render byte-identical and only flip the
// `data-animate` flag (which enables the straighten/spread transition) after
// mount for non-reduced users. The DOM structure never branches on `reduce`.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
// Required vars applied last so consumer style can never drop them.
const rootStyle: React.CSSProperties & Record<string, string> = {
...consumerStyle,
"--card-w": `${cardWidth}px`,
"--card-h": `${cardHeight}px`,
} as React.CSSProperties & Record<string, string>;
const rootClassName = [styles.fan, className].filter(Boolean).join(" ");
const cards = items.map((item, i) => {
const r = item.rotate ?? fanAngle(i, items.length, spread);
const CardTag = (item.href ? "a" : "div") as React.ElementType;
return createElement(
CardTag,
{
key: i,
className: styles.glass,
// resting rotation consumed by the CSS transform
style: { "--r": String(r) } as React.CSSProperties,
// the bottom strip label is painted from this attribute in CSS
"data-text": item.label,
// semantics: link cards are focusable & named; static cards are grouped
...(item.href
? { href: item.href, "aria-label": item.label }
: { role: "listitem" }),
},
<span key="icon" aria-hidden="true" className={styles.icon}>
{item.icon}
</span>,
);
});
return createElement(
Root,
{
...props,
className: rootClassName,
style: rootStyle,
"data-animate": animate ? "true" : "false",
role: "list",
},
...cards,
);
}
Dependencies
- motion
- @bottega/tokens