Skip to content
goodtabs UI
BrowseBrowse components

Resource card

The goodtabs resource cell: favicon, name, domain, description, tags and a bookmark.

npx shadcn@latest add https://ui.goodtabs.co/r/resource-card.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/resource-card

Searchable library of real app screens and flows, organised by pattern and product.

inspirationmobileNewFreemium

One cell with a New mark. Hover or tab to it to see the bookmark.

A grid of four

Astronomically good web design inspiration, one site at a time.

inspirationwebFree

Unstyled, accessible React components for menus, dialogs, tabs and more.

componentsreactNewOpen source

Searchable library of real app screens and flows, organised by pattern and product.

inspirationmobileFreemium

Issue tracking and roadmaps with the interface the rest of us keep copying.

toolsproductPaid

Cells share hairlines inside one frame. Each pricing badge appears once.

Saved

Copy and paste components built on Radix and Tailwind. The base goodtabs UI extends.

componentsreacttailwindOpen source

Saved to your list

Controlled saved state. The filled bookmark is one of the two brand marks.

Usage

import { ResourceCard } from "@/components/ui/resource-card"

<ResourceCard
  resource={{ name: "Mobbin", url: "https://mobbin.com", description: "Real app screens and flows.", tags: ["inspiration"], pricing: "freemium", isNew: true }}
  saved={saved}
  onSavedChange={setSaved}
/>

Props

PropTypeDefaultDescription
resource{ name; url; description; tags; pricing; isNew?; favicon? }The resource to show. pricing is free, freemium, paid or open-source.
savedbooleanControlled saved state. Leave undefined for uncontrolled.
onSavedChange(saved: boolean) => voidCalled with the next state when the bookmark is pressed.
onOpen(resource: Resource) => voidCalled when the link is followed.

Source

resource-card/resource-card.tsx
"use client";

import * as React from "react";
import { BookmarkIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export type ResourcePricing = "free" | "freemium" | "paid" | "open-source";

export type Resource = {
  name: string;
  url: string;
  description: string;
  tags: string[];
  pricing: ResourcePricing;
  /** Shows the New mark with the brand dot. */
  isNew?: boolean;
  /** An img or icon for the 20px tile. Falls back to the first letter of the name. */
  favicon?: React.ReactNode;
};

type ResourceCardProps = Omit<React.ComponentProps<"div">, "children" | "resource"> & {
  /** The resource to show. */
  resource: Resource;
  /** Controlled saved state. Leave undefined to let the card manage it. */
  saved?: boolean;
  /** Called with the next state when the bookmark is pressed. */
  onSavedChange?: (saved: boolean) => void;
  /** Called when the link is followed, for analytics or a recent list. */
  onOpen?: (resource: Resource) => void;
};

const PRICING_LABEL: Record<ResourcePricing, string> = {
  free: "Free",
  freemium: "Freemium",
  paid: "Paid",
  "open-source": "Open source",
};

const PRICING_CLASS: Record<ResourcePricing, string> = {
  free: "border-border text-muted-foreground",
  "open-source": "border-border text-muted-foreground",
  freemium: "border-transparent bg-muted text-foreground",
  paid: "border-transparent bg-primary text-primary-foreground",
};

function domainOf(url: string) {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

/**
 * The goodtabs resource cell: favicon, name, domain, two lines of description and a meta row.
 * The whole cell opens the url; the bookmark sits above it and is the one place the accent appears.
 */
export function ResourceCard({
  resource,
  saved: savedProp,
  onSavedChange,
  onOpen,
  className,
  ...props
}: ResourceCardProps) {
  const [savedState, setSavedState] = React.useState(false);
  const controlled = savedProp !== undefined;
  const saved = controlled ? savedProp : savedState;

  const toggle = () => {
    const next = !saved;
    if (!controlled) setSavedState(next);
    onSavedChange?.(next);
  };

  return (
    <div
      data-slot="resource-card"
      data-saved={saved || undefined}
      className={cn(
        "group/resource relative flex flex-col gap-2 bg-card p-4 text-sm transition-colors duration-100 ease-out hover:bg-muted has-[a:focus-visible]:bg-muted has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-ring/50 has-[a:focus-visible]:ring-inset",
        className,
      )}
      {...props}
    >
      <div className="flex min-w-0 items-center gap-2 pr-8">
        <span
          aria-hidden="true"
          className="inline-grid size-5 shrink-0 place-items-center overflow-hidden rounded-sm bg-muted text-xs leading-none font-medium text-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-3"
        >
          {resource.favicon ?? resource.name.charAt(0).toUpperCase()}
        </span>
        <a
          href={resource.url}
          target="_blank"
          rel="noopener noreferrer"
          onClick={onOpen ? () => onOpen(resource) : undefined}
          className="truncate font-medium text-foreground outline-none after:absolute after:inset-0 after:content-['']"
        >
          {resource.name}
          <span className="sr-only"> (opens in a new tab)</span>
        </a>
        <span className="truncate font-mono text-xs text-muted-foreground">{domainOf(resource.url)}</span>
      </div>
      <p className="m-0 line-clamp-2 text-muted-foreground">{resource.description}</p>
      <div className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-muted-foreground">
        {resource.tags.map((tag) => (
          <span key={tag}>{tag}</span>
        ))}
        {resource.isNew && (
          <span className="inline-flex items-center gap-1 text-foreground">
            <span aria-hidden="true" className="size-1.5 rounded-full bg-brand" />
            New
          </span>
        )}
        <span
          className={cn(
            "ml-auto inline-flex h-5 shrink-0 items-center rounded-sm border px-1.5 font-sans text-xs font-medium",
            PRICING_CLASS[resource.pricing],
          )}
        >
          {PRICING_LABEL[resource.pricing]}
        </span>
      </div>
      <button
        type="button"
        aria-pressed={saved}
        aria-label={saved ? "Saved" : "Save"}
        onClick={toggle}
        className={cn(
          "absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,color,background-color] duration-100 ease-out outline-none group-focus-within/resource:opacity-100 group-hover/resource:opacity-100 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:opacity-100",
          saved && "text-brand opacity-100 hover:text-brand",
        )}
      >
        <BookmarkIcon aria-hidden="true" className={cn("size-4", saved && "fill-current")} />
      </button>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/resource-card/prompt.md, or the Copy AI prompt button above.

When to use

  • A directory or library of links where each entry needs a name, a line and a save
  • Dense grids of 250px cells that share hairlines inside one frame
  • Bookmark lists, curated collections and tool roundups

When not to use

  • Internal navigation; the cell opens an external url in a new tab
  • Content with a picture or a long body; use an image card or a link card
  • When nothing can be saved; the bookmark is part of the cell

Agent notes

  • Client component: it holds the saved state when saved is undefined, and the demos pass onSavedChange
  • The name is a stretched link over the whole cell and the bookmark sits above it, so nothing is nested
  • The cell has no border or radius of its own: wrap one in a rounded-lg border, or many in a gap-px bg-border grid
  • The brand colour appears only on the New dot and the saved bookmark; badges stay monochrome