docs / core / glow-card

GlowCard

a Card whose border lights up on the edge facing the pointer, so a single card earns the directional glow BentoGrid shares across a whole grid. it takes every Card prop.

intensity · recedesrsc · server

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

the one card on a view that earns the pointer glow, a hero or a highlighted tieranywhere a plain Card goes: it is a drop-in with a lit edgenever a grid of them. shared glow across many cells is BentoGridnot on a touch-first surface. the glow needs a pointer to follow

a11y

the glow layer is aria-hidden · everything else is a plain Cardjest-axe

dependencies

Card from the same package
live demo · try it out

interactive

hover near the edges

Move your cursor toward the border. The lit arc turns to follow the pointer.

customize
surfaceshares Card's surface scale
eyebrowmono label
titlethe card heading
usagecopy
import { CardBody, CardEyebrow, CardHeader, CardTitle, GlowCard } from "usva/primitives/card";

<GlowCard>
  <CardHeader>
    <CardEyebrow>interactive</CardEyebrow>
    <CardTitle>hover near the edges</CardTitle>
  </CardHeader>
  <CardBody>Move your cursor toward the border.</CardBody>
</GlowCard>

props

proptypedefaultnotes
…CardPropsCardPropsevery Card prop applies: surface, highlight, interactive, and the rest.

get it

npx shadcn add https://usva.build/r/card.jsoncopy
source · components/ui/card.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";
import { Badge, type BadgeProps } from "./badge";

type Div = React.HTMLAttributes<HTMLDivElement>;
const make = (base: string, name: string) =>
  Object.assign(
    React.forwardRef<HTMLDivElement, Div>(({ className, ...p }, ref) => (
      <div ref={ref} className={cn(base, className)} {...p} />
    )),
    { displayName: name },
  );

export type CardHighlight = "none" | "wash" | "edge" | "ring";

/**
 * The shared surface vocabulary. One word picks how any card-like surface
 * (Card, StatCard, Panel, Dialog) sits on the page, and the same choice reads
 * consistently across all of them.
 *   elevated: the default. Lit-from-above surface fill, rim light, shadow.
 *   flat:     quiet surface fill, no lift. The sisu/dashboard workhorse.
 *   glass:    translucent, blurs what sits behind it. A rare, purposeful
 *             choice, never a default.
 *   outline:  transparent, carried by its border alone.
 */
export type CardSurface = "elevated" | "flat" | "glass" | "outline";

/** Background + rim treatment per surface. No shadow; consumers pick the level. */
export const SURFACE_SKIN: Record<CardSurface, string> = {
  elevated: "rim-light bg-surface",
  flat: "bg-surface",
  glass: "rim-light bg-surface/65 backdrop-blur-md",
  outline: "bg-transparent",
};

/** Whether a surface reads as raised (gets its host's elevation shadow). */
export const SURFACE_ELEVATED: Record<CardSurface, boolean> = {
  elevated: true,
  flat: false,
  glass: true,
  outline: false,
};

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
  interactive?: boolean;
  highlight?: CardHighlight;
  surface?: CardSurface;
  /** @deprecated use `highlight="wash"` */
  wash?: boolean;
}

export const Card = React.forwardRef<HTMLDivElement, CardProps>(
  (
    {
      className,
      interactive,
      highlight = "none",
      surface = "elevated",
      wash,
      ...p
    },
    ref,
  ) => {
    const resolved: CardHighlight =
      highlight === "none" && wash ? "wash" : highlight;
    return (
      <div
        ref={ref}
        data-interactive={interactive ? "" : undefined}
        data-highlight={resolved !== "none" ? resolved : undefined}
        data-surface={surface}
        className={cn(
          "relative isolate rounded-2xl border border-border text-ink",
          SURFACE_SKIN[surface],
          resolved === "ring"
            ? "glow-ring"
            : SURFACE_ELEVATED[surface] && "shadow-floating",
          resolved === "wash" && "wash-accent",
          resolved === "edge" &&
            "after:absolute after:inset-x-5 after:top-0 after:h-px after:hairline-accent",
          interactive &&
            "transition-control duration-base ease-soft hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none",
          interactive && resolved !== "ring" && "hover:shadow-overlay",
          className,
        )}
        {...p}
      />
    );
  },
);
Card.displayName = "Card";

export interface CardHeaderProps extends Div {
  row?: boolean;
}

export const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
  ({ className, row, ...p }, ref) => (
    <div
      ref={ref}
      className={cn(
        "relative p-5 after:absolute after:inset-x-5 after:bottom-0 after:h-px after:hairline-accent",
        row ? "flex flex-row items-start gap-4" : "flex flex-col gap-1.5",
        className,
      )}
      {...p}
    />
  ),
);
CardHeader.displayName = "CardHeader";

export const CardBody = make("p-5", "CardBody");
export const CardFooter = make(
  "flex items-center gap-2 p-5 border-t border-border",
  "CardFooter",
);

export const CardEyebrow = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...p }, ref) => (
  <p
    ref={ref}
    className={cn(
      "font-mono text-[0.65rem] uppercase leading-none tracking-[0.16em] text-muted",
      className,
    )}
    {...p}
  />
));
CardEyebrow.displayName = "CardEyebrow";

export interface CardTitleProps
  extends React.HTMLAttributes<HTMLHeadingElement> {
  /** Heading level. Pick it to fit the page outline. */
  as?: "h2" | "h3" | "h4" | "h5" | "h6";
}

export const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
  ({ className, as: Heading = "h3", ...p }, ref) => (
    <Heading
      ref={ref}
      className={cn(
        "text-balance text-sm font-semibold tracking-[-0.01em] text-ink",
        className,
      )}
      {...p}
    />
  ),
);
CardTitle.displayName = "CardTitle";

export const CardIcon = make(
  "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-muted [&_svg]:h-4 [&_svg]:w-4",
  "CardIcon",
);

export const CardActions = make(
  "ml-auto flex items-center gap-1",
  "CardActions",
);

export function CardBadge({ className, ...props }: BadgeProps) {
  return <Badge className={cn("self-start", className)} {...props} />;
}
CardBadge.displayName = "CardBadge";
source · components/ui/glow-card.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Card, type CardProps } from "./card";

/**
 * A Card whose border lights up on the edge facing the pointer. The arc turns
 * to follow the cursor and brightens as you approach the edge, so a single card
 * gets the same directional glow the BentoGrid shares across a whole grid.
 */
export const GlowCard = React.forwardRef<HTMLDivElement, CardProps>(
  ({ className, children, ...props }, ref) => {
    const localRef = React.useRef<HTMLDivElement | null>(null);
    const setRefs = React.useCallback(
      (node: HTMLDivElement | null) => {
        localRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      },
      [ref],
    );

    const onPointerMove = React.useCallback(
      (e: React.PointerEvent<HTMLDivElement>) => {
        const el = localRef.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        el.style.setProperty("--edge-x", `${e.clientX - r.left}px`);
        el.style.setProperty("--edge-y", `${e.clientY - r.top}px`);
        el.style.setProperty("--edge-o", "1");
      },
      [],
    );

    const onPointerLeave = React.useCallback(() => {
      localRef.current?.style.setProperty("--edge-o", "0");
    }, []);

    return (
      <Card
        ref={setRefs}
        className={cn("relative", className)}
        onPointerMove={onPointerMove}
        onPointerLeave={onPointerLeave}
        {...props}
      >
        <span aria-hidden className="edge-glow" />
        {children}
      </Card>
    );
  },
);
GlowCard.displayName = "GlowCard";