bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/glass-fan

Usage

Usagetsx
<GlassFan items={[]} />

Props

PropTypeDefaultDescription
items*GlassFanItem[]The cards to fan out, left to right.
spreadnumber40Total 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°.
cardWidthnumber180Card width in px (default 180).
cardHeightnumber200Card height in px (default 200).
classNamestringAdditional class names merged onto the root element.
asReact.ElementTypePolymorphic root element (default "div"). Use "ul"/"nav" for semantics.

Source

glass-fan.tsxtsx
"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