neon-ui
componentapp-cardsource: ok · hit
AppCard
Tenant dashboard grid card composing the status vocabulary: name, badges, timestamp, one-link hover.
npx shadcn add "https://ui.neon.com/r/app-card.json"free
Where it goes
statskanbandashboard-shell
Interaction
animated-counter
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@hugeicons/react@hugeicons/core-free-icons
Registry dependencies
utilshttps://ui.neon.com/r/neon-tokens.jsonhttps://ui.neon.com/r/status-badge.json
Files1 · source cached 10/13/2026, 9:29:33 AM
src/components/app-card/app-card.tsx
"use client";
import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ComponentProps, ReactNode } from "react";
import { PlanBadge, StatusBadge } from "@/components/status-badge/status-badge";
import type {
AppPlan,
AppStatus,
} from "@/components/status-badge/status-badge";
import { cn } from "@/lib/utils";
export type AppCardProps = Omit<ComponentProps<"a">, "children"> & {
/** App name, shown in mono. */
name: string;
/** Lifecycle state, rendered as a StatusBadge. */
status: AppStatus;
/** Billing plan, rendered as a PlanBadge. */
plan?: AppPlan;
/** One-line summary of what the app does. */
description?: string;
/** Last activity, already formatted, e.g. "2h ago". */
updatedAt?: string;
/**
* Status-colored grain rising from the bottom edge. Pass a ReactNode
* (e.g. a Paper Shaders GrainGradient) to replace the built-in CSS grain
* inside the same positioned, status-tinted slot.
*/
wash?: boolean | ReactNode;
};
/** Wash tint per status: color only ever comes from the vocabulary. */
const STATUS_WASH: Record<AppStatus, string> = {
error: "text-destructive",
provisioning: "text-primary",
ready: "text-primary",
stopped: "text-muted-foreground",
};
/* ─────────────────────────────────────────────────────────
* The dashboard grid card, on MetricCard's shell: hairline
* border warming on hover with a neon underline sweeping in
* under the name (transform-only, no layout shift). The
* corner arrow inks in alongside; the status vocabulary
* anchors the foot with the timestamp opposite. The whole
* card is one link.
* ───────────────────────────────────────────────────────── */
export const AppCard = ({
className,
description,
name,
plan,
status,
updatedAt,
wash = true,
...props
}: AppCardProps) => (
<a
className={cn(
"group relative isolate flex min-h-[128px] cursor-pointer select-none flex-col overflow-hidden rounded-lg border border-border/60 bg-card p-4 no-underline shadow-none ring-0 transition-colors hover:border-border focus-visible:border-primary focus-visible:outline-none",
className
)}
data-slot="app-card"
data-status={status}
{...props}
>
{wash === true ? (
<div
aria-hidden="true"
className={cn(
"neon-card-wash -z-10 pointer-events-none absolute inset-x-0 bottom-0 h-24 opacity-[0.07] transition-opacity duration-500 group-hover:opacity-[0.22]",
STATUS_WASH[status]
)}
/>
) : null}
{wash && wash !== true ? (
<div
aria-hidden="true"
className={cn(
"-z-10 pointer-events-none absolute inset-x-0 bottom-0 h-24 overflow-hidden",
STATUS_WASH[status]
)}
data-slot="app-card-wash"
>
{wash}
</div>
) : null}
<div className="flex items-center gap-1.5">
<p
title={name}
className="relative min-w-0 truncate font-mono font-semibold text-foreground text-sm after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-left after:scale-x-0 after:bg-primary after:transition-transform after:duration-300 after:ease-out group-hover:after:scale-x-100 motion-reduce:after:transition-none"
>
{name}
</p>
<HugeiconsIcon
aria-hidden="true"
icon={ArrowUpRight01Icon}
strokeWidth={2}
className="size-3.5 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-foreground"
/>
</div>
{description ? (
<p className="mt-1.5 line-clamp-2 max-w-[48ch] text-pretty text-muted-foreground/80 text-xs leading-5">
{description}
</p>
) : null}
<div className="mt-auto flex items-center gap-1.5 pt-4">
<StatusBadge status={status} />
{plan ? <PlanBadge plan={plan} /> : null}
{updatedAt ? (
<span className="ml-auto whitespace-nowrap font-mono text-[10px] text-muted-foreground/70 tabular-nums">
{updatedAt}
</span>
) : null}
</div>
</a>
);