UI

Glyph Card

A hairline card whose background resolves into tiny plus marks on hover, gathering inside the glyph. Draws itself from a single SVG path on a 24x24 grid.

Props

Accent
Glyph size62
Link cardWrap the card in an anchor

Installation

Glyph Card is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/glyph-card

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

import { GlyphCard } from "@/components/ui/glyph-card";
 
const NEXTJS_GLYPH =
  "M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z";
 
export function Stack() {
  return (
    <GlyphCard
      eyebrow="Framework"
      title="Next.js"
      subtitle="The React framework"
      href="https://nextjs.org"
      glyph={NEXTJS_GLYPH}
    />
  );
}

The only required props are eyebrow, title, and glyph. Everything else is optional.

The glyph

glyph is SVG path data authored on a 24x24 grid. Drop in any single-path brand mark and it lands centered in the card.

Omit href and the card renders as a static div instead of a link.

<GlyphCard eyebrow="Framework" title="Next.js" glyph={NEXTJS_GLYPH} />

Accent

accent tints the plus marks and the link arrow. It accepts any CSS color and defaults to var(--primary, #1447E6). Omitting it gives you the primary token, which reads black on white in most setups.

<GlyphCard
  eyebrow="Styling"
  title="Tailwind CSS"
  subtitle="Utility-first CSS"
  href="https://tailwindcss.com"
  glyph={TAILWIND_GLYPH}
  accent="#38BDF8"
/>

Recentering an off-grid mark

Most brand marks are already centered on the 24x24 grid and need nothing. A mark that is not, for example one whose art only occupies the bottom-right quadrant, renders off-center. Rather than rewriting the path coordinates, pass the offset that recenters it in grid units.

A mark whose bounding box runs x 10..22 and y 8..24 has a center of (16, 16), so it wants glyphOffset={[-4, -4]} to land on (12, 12).

<GlyphCard
  eyebrow="Monogram"
  title="Off-center mark"
  glyph={MONOGRAM_GLYPH}
  glyphOffset={[-4, -4]}
/>

Glyph size

glyphSize sets the size of the glyph viewport inside the card's 100x100 field. The reserved vertical space scales with it, so a larger glyph cannot outgrow its own card.

<GlyphCard
  glyphSize={78}
  eyebrow="Monogram"
  title="Larger glyph"
  glyph={MONOGRAM_GLYPH}
  glyphOffset={[-4, -4]}
/>

Props

GlyphCard

PropTypeDefaultNotes
eyebrow Requiredstring-Small mono label in the top-left.
title Requiredstring-Rendered as an h3 in the card footer.
glyph Requiredstring-SVG path data authored on a 24x24 grid.
subtitlestring-Muted second line under the title.
hrefstring-Makes the whole card a link. Omit for a static card.
glyphOffsetreadonly [number, number]-Shifts the glyph inside the viewBox, in grid units.
glyphSizenumber62Glyph viewport size inside the 100x100 field.
accentstringvar(--primary, #1447E6)Accent for the artwork and link arrow.
classNamestring-Extra classes for the root.

Notes & features

  • One path, no icon dependency. The card takes raw SVG path data rather than a React component, so it works with any icon set, including ones you do not want to install.
  • Hover and focus are the same state. Hovering, focusing, or tabbing to a card rotates the tiny plus marks 45 degrees, fades them, and gathers a brighter tint inside the glyph. The two are wired to one flag so a keyboard user sees exactly what a mouse user sees.
  • The tint is masked by the glyph. Two masks are generated from your path, one inverted, so the resting pattern is excluded from the glyph's shape and the hover tint is confined to it. Every card gets its own generated ids, so several cards can share a page.
  • Reduced motion. When the operating system asks for reduced motion, the transitions collapse and the plus marks hold at their resting angle and opacity. State changes still happen, they just stop animating.
  • Self-contained colors. accent and the pattern's mixing color read theme tokens with literal fallbacks, so the card renders correctly in a project that has neither this site's tokens nor a shadcn setup.
  • Sizing. The card fills its container's height (h-full) and reserves vertical space proportional to glyphSize. Put it in a grid cell or give the wrapper an explicit height.

Manual installation

Rather not use the CLI? Everything Glyph Card needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react motion

Source

components/ui/glyph-card.tsx364 lines
"use client";

import { ArrowUpRight } from "lucide-react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import { useId, useState, type CSSProperties } from "react";

import { cn } from "@/lib/utils";

const FIELD_BOX = 100;
const GLYPH_GRID = 24;
const DEFAULT_GLYPH_SIZE = 62;

/**
 * Plus marks and the grid they sit on. `TILE` is the pitch between marks and
 * `PLUS_ARM` is half the length of one, so the pair controls how dense and how
 * heavy the field reads. Smaller arms with a tighter tile give a finer grain.
 */
const TILE = 2.1;
const PLUS_ARM = 0.4;
const PLUS_STROKE = 0.18;
const PLUS_CENTER = TILE / 2;
const PLUS_PATH = `M${PLUS_CENTER - PLUS_ARM} ${PLUS_CENTER}h${PLUS_ARM * 2}M${PLUS_CENTER} ${PLUS_CENTER - PLUS_ARM}v${PLUS_ARM * 2}`;

/**
 * Hover defocus for the resting field.
 *
 * The blur is applied to a `<g>` inside the SVG, so the radius is in user
 * units, not CSS pixels: the field is a 100-unit viewBox stretched across the
 * card, so one unit is roughly 3.8px on a 380px card and `FIELD_BLUR` reads as
 * under 2px on screen.
 *
 * Blur averages pixels rather than moving them, so a mark thinner than the
 * radius loses its peak and fades to nothing. At the resting stroke of 0.18
 * units a defocused field does not go soft, it disappears. The stroke and the
 * opacity therefore grow together with the blur, which is what keeps the haze
 * visible instead of empty. Widen `FIELD_BLUR` and both have to follow.
 */
const FIELD_BLUR = 0.44;
const PLUS_STROKE_DEFOCUSED = 0.34;
const REST_OPACITY = 1;
const REST_OPACITY_DEFOCUSED = 0.5;

const FIELD_ORIGIN: CSSProperties = {
  transformBox: "view-box",
  transformOrigin: `${FIELD_BOX / 2}px ${FIELD_BOX / 2}px`,
};

const PLUS_ORIGIN: CSSProperties = {
  transformOrigin: `${PLUS_CENTER}px ${PLUS_CENTER}px`,
};

const FOCUS: Transition = {
  duration: 0.24,
  ease: [0.22, 1, 0.36, 1],
};

const REDUCED: Transition = { duration: 0 };

/**
 * Published-file fallbacks. `--primary` and `--card-foreground` are this site's
 * tokens, so a consumer project without them would otherwise render the artwork
 * in an invalid color. Literals mirror the light-theme token values.
 */
const FALLBACK_ACCENT = "#1447E6";
const FALLBACK_CARD_FOREGROUND = "#0A0A0A";

export interface GlyphCardProps {
  /** Small mono label in the top-left, for example "Runtime". */
  eyebrow: string;
  title: string;
  subtitle?: string;
  /** Makes the whole card a link. Omit for a static card. */
  href?: string;
  /** Link target attribute, for example "_blank". */
  target?: string;
  /** Link rel attribute, for example "noopener noreferrer". */
  rel?: string;
  /** Explicit accessible label for the card link. */
  "aria-label"?: string;
  /** SVG path data authored on a 24x24 grid. */
  glyph: string;
  /**
   * Shifts the glyph inside the 24x24 viewBox, in grid units.
   *
   * Most brand marks are already centered on the grid and need nothing. A mark
   * that is not, for example one whose art only occupies the bottom-right
   * quadrant, ends up off-center inside the card. Rather than rewriting the path
   * coordinates, pass the offset that recenters it: a mark whose bounding box
   * runs x 10..22 and y 8..24 has a center of (16, 16), so it wants
   * `glyphOffset={[-4, -4]}` to land on (12, 12).
   */
  glyphOffset?: readonly [number, number];
  /**
   * Size of the glyph viewport inside the 100x100 field.
   */
  glyphSize?: number;
  /** Accent for the artwork and link arrow. */
  accent?: string;
  className?: string;
}

export function GlyphCard({
  eyebrow,
  title,
  subtitle,
  href,
  target,
  rel,
  "aria-label": ariaLabel,
  glyph,
  glyphOffset,
  glyphSize = DEFAULT_GLYPH_SIZE,
  accent = `var(--primary, ${FALLBACK_ACCENT})`,
  className,
}: GlyphCardProps) {
  const uid = `gc-${useId().replace(/:/g, "-")}`;
  const restPatternId = `${uid}-rp`;
  const hoverPatternId = `${uid}-hp`;
  const outsideMaskId = `${uid}-om`;
  const insideMaskId = `${uid}-im`;

  const [isActive, setIsActive] = useState(false);
  const reduceMotion = useReducedMotion();
  const focus = reduceMotion ? REDUCED : FOCUS;
  const glyphScale = reduceMotion ? 1 : 0.965;
  // A quarter turn would look identical because a plus is 90-degree symmetric.
  // Forty-five degrees makes the background marks resolve into subtle x shapes.
  const plusRotation = reduceMotion || !isActive ? 0 : 45;

  /*
   * The glyph sits in a `glyphSize` box centered inside the 100x100 field, so
   * the inset is half the leftover space. Both masks share it, which is what
   * keeps the resting field and the hover field cut to the same shape.
   */
  const glyphInset = (FIELD_BOX - glyphSize) / 2;

  /*
   * Applied on a wrapping <g> so both masks move together. The transform is in
   * grid units, which are the units the glyph author already knows.
   */
  const glyphTransform = glyphOffset
    ? `translate(${glyphOffset[0]} ${glyphOffset[1]})`
    : undefined;

  /*
   * The SVG covers the complete card. preserveAspectRatio="none" lets the plus
   * field reach every edge, while each mask contains a nested 24x24 SVG that
   * preserves the glyph proportions inside a centered responsive viewport.
   */
  const field = (
    <svg
      viewBox={`0 0 ${FIELD_BOX} ${FIELD_BOX}`}
      preserveAspectRatio="none"
      aria-hidden="true"
      focusable="false"
      className="pointer-events-none absolute inset-0 z-0 block h-full w-full"
    >
      <motion.g
        animate={{ filter: isActive ? `blur(${FIELD_BLUR}px)` : "blur(0px)" }}
        transition={focus}
      >
        <rect
          width={FIELD_BOX}
          height={FIELD_BOX}
          fill={`url(#${restPatternId})`}
          mask={`url(#${outsideMaskId})`}
        />
      </motion.g>

      <motion.g
        style={FIELD_ORIGIN}
        animate={{
          opacity: isActive ? 1 : 0,
          scale: isActive && !reduceMotion ? 1.01 : glyphScale,
        }}
        transition={focus}
      >
        <rect
          width={FIELD_BOX}
          height={FIELD_BOX}
          fill={`url(#${hoverPatternId})`}
          mask={`url(#${insideMaskId})`}
        />
      </motion.g>

      <defs>
        <pattern
          id={restPatternId}
          width={TILE}
          height={TILE}
          patternUnits="userSpaceOnUse"
        >
          {/*
            No filter here. A filter on this path runs inside the pattern tile,
            which is only TILE units wide and clips its own content, so the blur
            is thrown away at the tile edge and the field flattens to nothing.
            The blur belongs on the group that paints the field, above.
          */}
          <motion.path
            d={PLUS_PATH}
            fill="none"
            stroke={`color-mix(in oklab, ${accent} 44%, var(--card-foreground, ${FALLBACK_CARD_FOREGROUND}))`}
            strokeWidth={PLUS_STROKE}
            strokeLinecap="round"
            style={PLUS_ORIGIN}
            animate={{
              rotate: plusRotation,
              opacity: isActive ? REST_OPACITY_DEFOCUSED : REST_OPACITY,
              strokeWidth: isActive ? PLUS_STROKE_DEFOCUSED : PLUS_STROKE,
            }}
            transition={focus}
          />
        </pattern>

        <pattern
          id={hoverPatternId}
          width={TILE}
          height={TILE}
          patternUnits="userSpaceOnUse"
        >
          <path
            d={PLUS_PATH}
            fill="none"
            stroke={accent}
            strokeWidth={PLUS_STROKE * 1.35}
            strokeLinecap="round"
          />
        </pattern>

        <mask
          id={outsideMaskId}
          maskUnits="userSpaceOnUse"
          x="0"
          y="0"
          width={FIELD_BOX}
          height={FIELD_BOX}
        >
          <rect width={FIELD_BOX} height={FIELD_BOX} fill="#fff" />
          <svg
            x={glyphInset}
            y={glyphInset}
            width={glyphSize}
            height={glyphSize}
            viewBox={`0 0 ${GLYPH_GRID} ${GLYPH_GRID}`}
            preserveAspectRatio="xMidYMid meet"
          >
            <g transform={glyphTransform}>
              <path d={glyph} fill="#000" />
            </g>
          </svg>
        </mask>

        <mask
          id={insideMaskId}
          maskUnits="userSpaceOnUse"
          x="0"
          y="0"
          width={FIELD_BOX}
          height={FIELD_BOX}
        >
          <svg
            x={glyphInset}
            y={glyphInset}
            width={glyphSize}
            height={glyphSize}
            viewBox={`0 0 ${GLYPH_GRID} ${GLYPH_GRID}`}
            preserveAspectRatio="xMidYMid meet"
          >
            <g transform={glyphTransform}>
              <path d={glyph} fill="#fff" />
            </g>
          </svg>
        </mask>
      </defs>
    </svg>
  );

  const isExternal =
    target === "_blank" ||
    (typeof href === "string" &&
      (href.startsWith("http://") || href.startsWith("https://")));

  const content = (
    <>
      <div className="relative z-10 flex min-w-0 items-start justify-between gap-3">
        <span className="min-w-0 rounded-sm bg-card/90 px-1.5 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-[1px] [overflow-wrap:anywhere]">
          {eyebrow}
        </span>
        {href ? (
          <span className="grid size-6 shrink-0 place-items-center rounded-full bg-card/90 backdrop-blur-[1px]">
            <ArrowUpRight
              aria-hidden="true"
              className="size-3.5 transition-transform duration-[220ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:group-hover:-translate-y-0.5 motion-safe:group-hover:translate-x-0.5 motion-safe:group-focus-visible:-translate-y-0.5 motion-safe:group-focus-visible:translate-x-0.5"
            />
            {isExternal && (
              <span className="sr-only">(opens external link)</span>
            )}
          </span>
        ) : null}
      </div>

      <div aria-hidden="true" className="min-h-40 sm:min-h-48" />

      <div className="relative z-10 grid min-w-0 gap-1 [overflow-wrap:anywhere]">
        <h3 className="text-base leading-snug font-medium tracking-tight text-card-foreground">
          {title}
        </h3>
        {subtitle ? (
          <p className="text-sm leading-relaxed text-muted-foreground">{subtitle}</p>
        ) : null}
      </div>
    </>
  );

  const rootClass = cn(
    "group relative grid h-full w-full min-w-0 grid-rows-[auto_1fr_auto] gap-4 overflow-hidden rounded-none",
    "bg-background p-2 ring-1 ring-inset ring-border sm:p-5",
    // Mirrors the site's `.micro-transition`, inlined so the published file
    // carries no dependency on a class that only exists in this project.
    "transition-[transform,background-color,border-color,color,opacity,box-shadow]",
    "duration-[220ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
    className,
  );

  const interactionProps = {
    onHoverStart: () => setIsActive(true),
    onHoverEnd: () => setIsActive(false),
    onFocus: () => setIsActive(true),
    onBlur: () => setIsActive(false),
  };

  const style = { color: accent };

  if (href) {
    const computedRel =
      rel ?? (target === "_blank" ? "noopener noreferrer" : undefined);
    return (
      <motion.a
        href={href}
        target={target}
        rel={computedRel}
        aria-label={ariaLabel}
        className={rootClass}
        style={style}
        {...interactionProps}
      >
        {field}
        {content}
      </motion.a>
    );
  }

  return (
    <motion.div
      className={cn(rootClass, "cursor-default")}
      style={style}
      {...interactionProps}
    >
      {field}
      {content}
    </motion.div>
  );
}