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-cardUsage
Usagetsx
<FeatureCard icon={undefined} title="" description="" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| icon* | React.ReactNode | — | Decorative icon rendered inside the recessed pill slot (e.g. a Lucide icon). |
| title* | string | — | Card headline. |
| description* | string | — | Supporting body copy — 1–3 sentences. |
| as | React.ElementType | — | Polymorphic root element; defaults to "div". Pass "a" or a Link for linked cards. |
| href | string | — | If provided, makes the whole card a focusable link (implies as="a"). |
| headingLevel | enum | span | Heading element for the title so landmark hierarchy stays caller-controlled. |
| glowColor | string | — | Optional CSS color for the conic border glow. Defaults to var(--primary). |
| index | number | 0 | Stagger index when used in a grid — entry delay = index * 60ms. |
| className | string | Passthrough 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