bottega

sections

Feature Card

A restrained-premium feature card whose border is invisible at rest and 'charges' into view on hover via a conic-gradient spotlight that tracks pointer angle around the edge (mask-composite:exclude isolates only the ring — no box-shadow bloom). A soft radial wash bleeds inward from the cursor quadrant for depth without heavy glassmorphism. The icon sits in a recessed muted pill that lifts on hover. Polymorphic root (div/a/Next-Link), caller-controlled heading level, and a fully declarative reduced-motion fallback.

Edge-charged borders

The ring stays invisible until you approach it, then a conic spotlight tracks your pointer around the card edge.


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/feature-card

Usage

Usagetsx
<FeatureCard icon={undefined} title="" description="" />

Props

PropTypeDefaultDescription
icon*React.ReactNodeDecorative icon rendered inside the recessed pill slot (e.g. a Lucide icon).
title*stringCard headline.
description*stringSupporting body copy — 1–3 sentences.
asReact.ElementTypePolymorphic root element; defaults to "div". Pass "a" or a Link for linked cards.
hrefstringIf provided, makes the whole card a focusable link (implies as="a").
headingLevelenumspanHeading element for the title so landmark hierarchy stays caller-controlled.
glowColorstringOptional CSS color for the conic border glow. Defaults to var(--primary).
indexnumber0Stagger index when used in a grid — entry delay = index * 60ms.
classNamestringPassthrough class for layout overrides (grid placement, width, etc).

Source

feature-card.tsxtsx
"use client";

import { createElement, useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import styles from "./feature-card.module.css";

export type FeatureCardProps = {
  /** Decorative icon rendered inside the recessed pill slot (e.g. a Lucide icon). */
  icon: React.ReactNode;
  /** Card headline. */
  title: string;
  /** Supporting body copy — 1–3 sentences. */
  description: string;
  /** Polymorphic root element; defaults to "div". Pass "a" or a Link for linked cards. */
  as?: React.ElementType;
  /** If provided, makes the whole card a focusable link (implies as="a"). */
  href?: string;
  /** Heading element for the title so landmark hierarchy stays caller-controlled. */
  headingLevel?: "h2" | "h3" | "span";
  /** Optional CSS color for the conic border glow. Defaults to var(--primary). */
  glowColor?: string;
  /** Stagger index when used in a grid — entry delay = index * 60ms. */
  index?: number;
  /** Passthrough class for layout overrides (grid placement, width, etc). */
  className?: string;
};

export function FeatureCard({
  icon,
  title,
  description,
  as,
  href,
  headingLevel = "span",
  glowColor,
  index = 0,
  className = "",
}: FeatureCardProps) {
  const ref = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduce check (contract snippet) — server HTML and the
  // first client render are byte-identical static; pointer tracking + entry
  // animation turn on only after hydration for non-reduced users.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  // Pure-CSS-driven: compute pointer angle + position, write them as custom
  // properties on the root. No measurement at first paint (effect-gated start,
  // values only read on a real pointer event).
  function handlePointerMove(e: React.PointerEvent<HTMLElement>) {
    if (!animate || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const px = e.clientX - rect.left;
    const py = e.clientY - rect.top;
    const angle =
      Math.atan2(py - rect.height / 2, px - rect.width / 2) * (180 / Math.PI) + 90;
    ref.current.style.setProperty("--angle", `${angle}deg`);
    ref.current.style.setProperty("--pointer-x", `${px}px`);
    ref.current.style.setProperty("--pointer-y", `${py}px`);
  }

  const isLink = Boolean(href) || as === "a";
  const Heading = headingLevel;

  // Polymorphic root: anchor when linked, else the requested element (default div).
  // motion.create wraps a custom component (e.g. Next/Link); native tags use the
  // built-in motion proxies so DOM props/refs stay correctly typed. Derived from
  // props every render so a later href/as change keeps the root in sync; only the
  // custom-component branch is memoized (motion.create returns a fresh component
  // each call, which would otherwise remount the subtree on every render).
  const customRoot = useMemo<React.ElementType | null>(
    () => (typeof as === "function" || typeof as === "object" ? motion.create(as) : null),
    [as],
  );
  let Root: React.ElementType;
  if (href || as === "a") Root = motion.a;
  else if (!as || as === "div") Root = motion.div;
  else if (typeof as === "string") Root = motion[as as "span"] ?? motion.div;
  else Root = customRoot ?? motion.div;

  // Entry animation (whileInView) — seconds/easing, NOT the --duration CSS token,
  // which governs CSS UI transitions (~300ms). Bespoke scroll-entry timing.
  const motionProps = animate
    ? {
        initial: { opacity: 0, y: 16 },
        whileInView: { opacity: 1, y: 0 },
        viewport: { once: true, margin: "-10%" },
        transition: { duration: 0.5, ease: [0.4, 0, 0.2, 1] as const, delay: index * 0.06 },
      }
    : {};

  // Polymorphic motion root via createElement — avoids JSX widening `Root` over
  // every element type (which collapses `children` to `never`).
  return createElement(
    Root,
    {
      ref,
      href,
      onPointerMove: handlePointerMove,
      "data-slot": "feature-card",
      className: [
        styles.card,
        "group block",
        "bg-background",
        "rounded-[calc(var(--radius)*1.5)]",
        // base ring: border token at ~20% opacity
        "border border-border/20",
        "p-6",
        "no-underline",
        isLink ? "cursor-pointer" : "",
        // focus-visible ring from --ring — never removed, only styled
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
        className,
      ]
        .filter(Boolean)
        .join(" "),
      style: glowColor
        ? ({ "--glow-color": glowColor } as React.CSSProperties)
        : undefined,
      ...motionProps,
    },
    <div className={[styles.surface, "flex flex-col gap-4"].join(" ")}>
      <span className={styles.dimVeil} aria-hidden="true" />
      <span
        aria-hidden="true"
        className={[
          styles.iconPill,
          "inline-flex items-center justify-center",
          "size-11 shrink-0",
          "bg-muted",
          "rounded-[var(--radius)]",
          "text-foreground",
        ].join(" ")}
      >
        {icon}
      </span>
      <Heading className="text-base font-semibold text-foreground">{title}</Heading>
      <p className="text-sm leading-[1.6] text-foreground/70">{description}</p>
    </div>,
  );
}

Dependencies

  • motion
  • @bottega/tokens