diff --git a/src/components/LibraryCard.tsx b/src/components/LibraryCard.tsx
new file mode 100644
index 00000000..cb313f23
--- /dev/null
+++ b/src/components/LibraryCard.tsx
@@ -0,0 +1,191 @@
+import * as React from "react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faBuildingColumns } from "@fortawesome/free-solid-svg-icons";
+
+/**
+ * Size of the logo tile and the minimum height of a card's content, in
+ * pixels. 44px is the minimum comfortable touch target size.
+ */
+const TILE_SIZE = 44;
+
+/**
+ * Marks the card's main interactive element. Its ::after pseudo-element
+ * stretches over the whole card, so a click anywhere on the card activates
+ * it without adding the card itself to the tab order.
+ */
+const ACTION_ATTRIBUTE = "data-library-card-action";
+
+/** Props the card's interactive element must spread onto itself. */
+export type LibraryCardActionProps = {
+ [ACTION_ATTRIBUTE]: "";
+ "aria-describedby"?: string;
+};
+
+interface LibraryCardProps {
+ logoUrl?: string;
+ description?: string;
+ /**
+ * Renders the card's interactive element (a link or button carrying the
+ * library title), which must spread `actionProps` onto itself.
+ */
+ children: (actionProps: LibraryCardActionProps) => React.ReactNode;
+ /**
+ * Rendered below the card's main row and stacked over the stretched click
+ * target, so its controls stay clickable.
+ */
+ footer?: React.ReactNode;
+}
+
+/** Shown in the logo tile when a library has no logo or it fails to load. */
+const DefaultLibraryLogo: React.FC = () => (
+
+);
+
+/*
+ * The frame around the logo or default logo. It shows as a blank tile while
+ * a logo loads.
+ */
+const tileStyle = {
+ height: TILE_SIZE,
+ width: TILE_SIZE,
+ flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ p: "2px",
+ border: "1px solid",
+ borderColor: "ui.gray.light",
+ borderRadius: 2,
+ backgroundColor: "ui.white"
+} as const;
+
+/**
+ * One library in a list: a clickable card with a logo tile, the title, and
+ * an optional description. The description is attached to the interactive
+ * element with aria-describedby, so screen readers announce it with the
+ * title. The tile shows a generic library icon when there is no logo or the
+ * logo fails to load, and stays blank while the logo loads.
+ */
+const LibraryCard: React.FC = ({
+ logoUrl,
+ description,
+ children,
+ footer
+}) => {
+ const descriptionId = React.useId();
+ const [failedLogoUrl, setFailedLogoUrl] = React.useState();
+ const showLogo = logoUrl && logoUrl !== failedLogoUrl;
+ const action = `[${ACTION_ATTRIBUTE}]`;
+
+ return (
+