Navigation

Link card

A card that is one link, with a title, description, icon and meta line, for docs indexes and hub pages.

Built on
<a href>::after hit area:has()
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.9 kB
Built from
Icon
Try its options

Loading its options.

Code
import { LinkCard } from "@/components/equip/link-card";

export default function Demo() {
  return (
    <LinkCard
      style={{ width: 216 }}
      href="#webhooks"
      icon="bolt"
      title="Webhooks"
      description="Send events to your server."
    />
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:216px" class="eq-link-card">
  <span class="eq-link-card-icon" aria-hidden="true">
    <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M13 3 5 13.5h6.5L11 21l8-10.5h-6.5z" />
    </svg>
  </span>
  <div class="eq-link-card-body">
    <h3 class="eq-link-card-title">
      <a class="eq-link-card-link" href="#webhooks">Webhooks</a>
    </h3>
    <p class="eq-link-card-description">Send events to your server.</p>
  </div>
  <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
    <path d="M4.5 12h15" />
    <path d="M13.5 6l6 6-6 6" />
  </svg>
</div>

Examples

Docs hub

Each title is an h3 inside its own link, so the grid can be browsed by heading and each card is one tab stop.

Code
import { LinkCard } from "@/components/equip/link-card";

export default function Example() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, width: "100%" }}>
      <LinkCard
        href="#quickstart"
        icon="rocket"
        title="Quickstart"
        description="Deploy your first project from a Git repository."
        meta="5 min read"
      />
      <LinkCard
        href="#environment-variables"
        icon="key"
        title="Environment variables"
        description="Keep secrets out of your code and scope them per branch."
        meta="8 min read"
      />
      <LinkCard
        href="#custom-domains"
        icon="globe"
        title="Custom domains"
        description="Point a domain you own at a deployment and issue a certificate."
        meta="6 min read"
      />
      <LinkCard
        href="#cli"
        icon="terminal"
        title="Command line"
        description="Build, preview and roll back from your terminal."
        meta="12 commands"
      />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:12px;width:100%">
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M12 2.5c3 2 4.5 5.5 4.5 9.5v4.5h-9V12c0-4 1.5-7.5 4.5-9.5z" />
        <path d="M7.5 12.5 5 15v3.5h2.5M16.5 12.5 19 15v3.5h-2.5" />
        <path d="M10.5 9a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0 -3 0" />
        <path d="M12 19.25v2" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#quickstart">Quickstart</a>
      </h3>
      <p class="eq-link-card-description">Deploy your first project from a Git repository.</p>
      <p class="eq-link-card-meta">5 min read</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M4 16a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
        <path d="M10.83 13.17l8.67-8.67" />
        <path d="M17 7l2.5 2.5M14 10l2 2" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#environment-variables">Environment variables</a>
      </h3>
      <p class="eq-link-card-description">Keep secrets out of your code and scope them per branch.</p>
      <p class="eq-link-card-meta">8 min read</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
        <path d="M3 12h18" />
        <path d="M12 3c-2.5 2.5-3.75 5.5-3.75 9s1.25 6.5 3.75 9c2.5-2.5 3.75-5.5 3.75-9S14.5 5.5 12 3z" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#custom-domains">Custom domains</a>
      </h3>
      <p class="eq-link-card-description">Point a domain you own at a deployment and issue a certificate.</p>
      <p class="eq-link-card-meta">6 min read</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M5 4.5h14a2 2 0 0 1 2 2v11a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" />
        <path d="M7 9.5l3 2.5-3 2.5" />
        <path d="M12.5 15h4.5" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#cli">Command line</a>
      </h3>
      <p class="eq-link-card-description">Build, preview and roll back from your terminal.</p>
      <p class="eq-link-card-meta">12 commands</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
</div>

Leaving the product

external opens a new tab, turns the arrow outward and adds "opens in a new tab" to the link's name.

Code
import { LinkCard } from "@/components/equip/link-card";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 420 }}>
      <LinkCard
        href="https://status.example.com"
        external
        icon="activity"
        title="System status"
        description="Live uptime and incident history for every region."
      />
      <LinkCard
        href="https://community.example.com"
        external
        icon="messages"
        title="Community forum"
        description="Ask questions and share what you have built."
      />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:100%;max-width:420px">
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M2.5 12h4l3-7.5 5 15 3-7.5h4" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a target="_blank" rel="noopener noreferrer" class="eq-link-card-link" href="https://status.example.com">System status<span class="eq-link-card-hidden"> (opens in a new tab)</span></a>
      </h3>
      <p class="eq-link-card-description">Live uptime and incident history for every region.</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M6 18 18 6" />
      <path d="M9 6h9v9" />
    </svg>
  </div>
  <div class="eq-link-card">
    <span class="eq-link-card-icon" aria-hidden="true">
      <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
        <path d="M10 9h9a2 2 0 0 1 2 2v5.5a2 2 0 0 1-2 2h-.5V21L15 18.5h-5a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z" />
        <path d="M15.5 5.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2v2.5L8 13" />
      </svg>
    </span>
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a target="_blank" rel="noopener noreferrer" class="eq-link-card-link" href="https://community.example.com">Community forum<span class="eq-link-card-hidden"> (opens in a new tab)</span></a>
      </h3>
      <p class="eq-link-card-description">Ask questions and share what you have built.</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M6 18 18 6" />
      <path d="M9 6h9v9" />
    </svg>
  </div>
</div>

Without an icon

Leave out icon for a plain index of guides; the card is still one link from edge to edge.

Code
import { LinkCard } from "@/components/equip/link-card";

export default function Example() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 12, width: "100%" }}>
      <LinkCard href="#migrate-from-v2" title="Migrate from version 2" description="What changed in routing, caching and configuration." />
      <LinkCard href="#release-notes" title="Release notes" description="Every change since the last major version." meta="Updated 3 days ago" />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:12px;width:100%">
  <div class="eq-link-card">
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#migrate-from-v2">Migrate from version 2</a>
      </h3>
      <p class="eq-link-card-description">What changed in routing, caching and configuration.</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
  <div class="eq-link-card">
    <div class="eq-link-card-body">
      <h3 class="eq-link-card-title">
        <a class="eq-link-card-link" href="#release-notes">Release notes</a>
      </h3>
      <p class="eq-link-card-description">Every change since the last major version.</p>
      <p class="eq-link-card-meta">Updated 3 days ago</p>
    </div>
    <svg aria-hidden="true" focusable="false" class="eq-icon eq-link-card-arrow" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <path d="M4.5 12h15" />
      <path d="M13.5 6l6 6-6 6" />
    </svg>
  </div>
</div>

Props

PropTypeDefaultWhat it does
hrefstringnoneWhere the card leads.
titleReactNodenoneThe link text and the card's accessible name.
descriptionReactNodenoneOne or two lines under the title.
iconIconName | ReactElementnoneAn Icon name such as "rocket", or your own element, shown in a tile before the text.
metaReactNodenoneA small line under the description, such as a read time or a count.
headingLevel2 | 3 | 4 | 5 | 63Heading level of the title, to fit the outline of the page.
externalbooleanfalseOpens in a new tab, turns the arrow outward and adds "(opens in a new tab)" for screen readers.
linkPropsComponentProps<"a">noneExtra attributes for the link itself, such as onClick for a client router or a download attribute.
...restComponentProps<"div">noneEvery native div attribute, applied to the card.

Anatomy

root.eq-link-cardThe card. Draws the focus ring while its link has keyboard focus.
icon.eq-link-card-iconA bordered tile holding the icon, hidden from assistive tech.
body.eq-link-card-body
title.eq-link-card-titleThe heading.
link.eq-link-card-linkThe only link. Its ::after covers the whole card, so the card is the hit area.
description.eq-link-card-description
meta.eq-link-card-meta
arrow.eq-link-card-arrowMoves a little toward the reading direction on hover.
hidden text.eq-link-card-hiddenOnly with external. Visually hidden.

Keyboard

EnterFollows the link.

Notes

  • There is one link per card and its name is the title alone, so a list of links stays short; the description is read in sequence after it.
  • The link's ::after covers the card, so a click anywhere follows it; the text cannot be selected with the pointer as a result.
  • Do not put other links or buttons inside a link card; they would sit under the covering link and could not be clicked.
  • Renders on the server with no client JavaScript.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/link-card.tsx63 lines
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./link-card.css";

export type LinkCardProps = Omit<ComponentProps<"div">, "title"> & {
  /** Where the card leads. */
  href: string;
  /** The link text and the card's accessible name. Keep it short. */
  title: ReactNode;
  /** One or two lines under the title. */
  description?: ReactNode;
  /** An icon name from Icon, or your own element, shown before the text. */
  icon?: IconName | ReactElement;
  /** A small line under the description, such as a read time or a count. */
  meta?: ReactNode;
  /** The heading level of the title, so a grid of cards can be browsed by heading. */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  /** Opens in a new tab, swaps the arrow and says so to screen readers. */
  external?: boolean;
  /** Extra attributes for the link itself, such as onClick for a client router. */
  linkProps?: Omit<ComponentProps<"a">, "href" | "children">;
};

export function LinkCard({
  href,
  title,
  description,
  icon,
  meta,
  headingLevel = 3,
  external = false,
  linkProps,
  className,
  ...rest
}: LinkCardProps) {
  const Heading = `h${headingLevel}` as "h3";
  const linkClass = ["eq-link-card-link", linkProps?.className].filter(Boolean).join(" ");
  return (
    <div {...rest} className={["eq-link-card", className].filter(Boolean).join(" ")}>
      {icon != null && (
        <span className="eq-link-card-icon" aria-hidden="true">
          {typeof icon === "string" ? <Icon name={icon} size="m" /> : icon}
        </span>
      )}
      <div className="eq-link-card-body">
        <Heading className="eq-link-card-title">
          <a
            {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
            {...linkProps}
            className={linkClass}
            href={href}
          >
            {title}
            {external && <span className="eq-link-card-hidden"> (opens in a new tab)</span>}
          </a>
        </Heading>
        {description != null && <p className="eq-link-card-description">{description}</p>}
        {meta != null && <p className="eq-link-card-meta">{meta}</p>}
      </div>
      <Icon className="eq-link-card-arrow" name={external ? "arrow-up-right" : "arrow-right"} size="s" />
    </div>
  );
}
components/equip/link-card.css137 lines
@layer equip {
  .eq-link-card {
    position: relative;
    box-sizing: border-box;
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-3);
    min-width: 0;
    padding: var(--eq-space-4);
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.45 var(--eq-font);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-link-card-icon {
    flex: none;
    display: grid;
    place-items: center;
    width: var(--eq-h-m);
    height: var(--eq-h-m);
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
  }

  .eq-link-card-icon > svg {
    width: 20px;
    height: 20px;
  }

  .eq-link-card-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
  }

  .eq-link-card-title {
    margin: 0;
    font: inherit;
    font-weight: var(--eq-weight-strong);
    overflow-wrap: anywhere;
  }

  .eq-link-card-link {
    color: inherit;
    text-decoration: none;
    outline: none;
  }

  /* The one link covers the whole card, so the hit area is the card and there is one tab stop. */
  .eq-link-card-link::after {
    content: "";
    position: absolute;
    inset: -1px;
    z-index: 1;
    border-radius: inherit;
  }

  .eq-link-card-description {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-link-card-meta {
    margin: var(--eq-space-1) 0 0;
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
    font-variant-numeric: tabular-nums;
  }

  .eq-link-card-arrow {
    flex: none;
    margin-top: 2px;
    color: var(--eq-ink-3);
    transition:
      color var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-link-card-arrow:dir(rtl) {
    scale: -1 1;
  }

  .eq-link-card-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  @media (hover: hover) {
    .eq-link-card:hover {
      border-color: var(--eq-line-strong);
      background: color-mix(in srgb, var(--eq-surface) 95%, var(--eq-ink));
    }

    .eq-link-card:hover .eq-link-card-title {
      text-decoration: underline;
      text-decoration-color: color-mix(in srgb, currentColor 42%, transparent);
      text-underline-offset: 0.22em;
    }

    .eq-link-card:hover .eq-link-card-arrow {
      color: var(--eq-ink);
      translate: 2px 0;
    }

    .eq-link-card:hover .eq-link-card-arrow:dir(rtl) {
      translate: -2px 0;
    }
  }

  .eq-link-card:active {
    scale: var(--eq-press);
  }

  /* The ring goes round the whole card, since the whole card is the link. */
  .eq-link-card:has(.eq-link-card-link:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index