The 3 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Profile card"
Library page: https://astro.baysixmedia.com/components/profile-card/
Files: 3 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/ProfileCard.astro =====
---
/**
* Profile card
*
* A square portrait in a rounded frame, a name with an optional verified
* badge, a short bio, and a footer row of counts beside a view control.
*
* The control is a single action. Given an `href` it renders as a link and
* the component ships no behaviour at all; without one it is a button that
* dispatches an event the host can listen for.
*
* Self-contained: no imports, no packages, no global stylesheet, no webfont.
* Every colour, size and duration is a `--pcd-*` custom property on the root.
*/
export interface ProfileCardStat {
/**
* The figure, already formatted — "1,219", "2.4k", "172". Leave it out
* and the item is the icon alone, which is what the default card does:
* three marks saying what this person offers, not how many of it.
*/
value?: string;
/**
* What it is. Not shown; it is the accessible name for the item, and it
* is the ONLY thing read when there is no figure beside it, so it has to
* say something on its own.
*/
label: string;
/** Which glyph sits before it, or instead of it. */
icon?:
| "cross"
| "phone"
| "website"
| "followers"
| "photos"
| "posts"
| "none";
}
interface Props {
/** Who the card is for. */
name?: string;
/** One or two lines under the name. `""` hides it. */
bio?: string;
/** Portrait. Square; anything else is cropped to the centre. */
image?: string;
/**
* Alternative text. Left empty the portrait is marked decorative, which
* is right when the name beside it already identifies the person.
*/
imageAlt?: string;
/** Show the check beside the name. */
verified?: boolean;
/** Accessible name for that check. */
verifiedLabel?: string;
/** Up to three items in the footer. A fourth is dropped, not wrapped. */
stats?: ProfileCardStat[];
/** Footer control text. `""` drops the control entirely. */
actionLabel?: string;
/**
* Render the control as a link to this URL, which is the only form that
* works without JavaScript. Without one the control is a button that
* announces itself with a `profile-card:view` event and leaves the rest
* to the host.
*/
href?: string;
/** Heading level for the name. */
headingLevel?: "h2" | "h3" | "h4" | "p";
/** The card's fill. */
background?: string;
/** Name, figures and control text. */
ink?: string;
/** The verified check. */
accent?: string;
/** Hairline around the portrait. */
frameColor?: string;
/** Card corner radius. The portrait uses the same. */
radius?: string;
/** Added to the root. */
class?: string;
/** Root element id. */
id?: string;
}
const {
name = "Ada Whitfield",
bio = "Pre and post-operative breast care specialist.",
image = "/profile-card/portrait-1.svg",
imageAlt = "",
verified = false,
verifiedLabel = "Verified account",
stats = [
{ label: "Clinical care", icon: "cross" },
{ label: "Phone", icon: "phone" },
{ label: "Website", icon: "website" },
],
actionLabel = "View",
href,
headingLevel = "h2",
background = "#000000",
ink = "#ffffff",
accent = "#10b981",
frameColor = "rgb(0 0 0 / 0.1)",
radius = "32px",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/*
* Only schemes that navigate. The card is copied into other people's
* projects where the href may come from a profile record rather than from
* the person writing the template.
*/
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;
const link = safeHref(href);
/* An anchor when there is somewhere to go, a button when there is not. The
two differ by four attributes, so they are one element rather than two
branches holding copies of the same glyph. */
const Control = link ? "a" : "button";
const src = safeHref(image);
/* Three is what the footer row has room for beside the control once the
items are bare icons; a fourth would push the button onto its own line
rather than fitting. Measured at 320px: three 20px marks and their gaps
come to 84px of the 280px the row has, leaving the control its 111. */
const figures = stats.slice(0, 3);
/* Icons are inlined rather than fetched, so the card makes no request of its
own beyond the portrait. Drawn on a 24 grid at stroke 2, like the rest. */
const GLYPHS: Record<string, string[]> = {
/* An outlined Greek cross rather than a bare plus: the plus is the
universal "add", and this has to read as medical at 20px. */
cross: ["M9 3h6v6h6v6h-6v6H9v-6H3V9h6z"],
phone: [
"M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z",
"M11 18h2",
],
/* The ring is drawn as two arcs rather than a <circle>, so every glyph in
this table is a path list and the renderer stays one loop. */
website: [
"M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z",
"M2 12h20",
"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z",
],
followers: ["M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"],
photos: [
"M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 18.5z",
"m4 16 4.6-4.6a2 2 0 0 1 2.8 0L16 16",
"m14 14 1.6-1.6a2 2 0 0 1 2.8 0L20 14",
],
posts: [
"M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z",
"M8 9h8M8 13h8M8 17h5",
],
};
const style = [
`--pcd-bg:${background}`,
`--pcd-ink:${ink}`,
`--pcd-accent:${accent}`,
`--pcd-frame:${frameColor}`,
`--pcd-radius:${radius}`,
].join(";");
---
<article
class:list={["profile-card", className]}
id={id}
style={style}
data-profile-card
>
<div class="pcd-frame">
{src ? (
<img
class="pcd-photo"
src={src}
alt={imageAlt}
width="800"
height="800"
loading="lazy"
decoding="async"
/>
) : (
<div class="pcd-photo pcd-photo--empty" aria-hidden="true"></div>
)}
</div>
<div class="pcd-body">
<div class="pcd-nameline">
<Heading class="pcd-name">{name}</Heading>
{verified && (
<svg
class="pcd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
role="img"
aria-label={verifiedLabel}
>
<path
d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"
></path>
<path d="m9 12 2 2 4-4"></path>
</svg>
)}
</div>
{bio && <p class="pcd-bio">{bio}</p>}
<div class="pcd-footer">
{figures.map((stat) => (
<p class="pcd-stat">
{stat.icon !== "none" && GLYPHS[stat.icon ?? "followers"] && (
<svg
class="pcd-stat-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{GLYPHS[stat.icon ?? "followers"].map((d) => <path d={d}></path>)}
{(stat.icon ?? "followers") === "followers" && (
<circle cx="12" cy="7" r="4"></circle>
)}
</svg>
)}
{stat.value && <span class="pcd-stat-value">{stat.value}</span>}
{/* The figure on its own says "1,219" and nothing else, and an
icon on its own says less than that. The word is here rather
than in a title attribute so it is read, not hovered for. */}
<span class="pcd-a11y">{stat.label}</span>
</p>
))}
{actionLabel && (
/*
One element either way.
With an `href` it is a link: served visible, works with no
JavaScript at all, and nothing in the controller below
matches it.
Without one it is a button, served `hidden` and revealed
once the controller can make it do something. The only thing
a button here can do is tell the host it was pressed, and
that needs script — so a scripting-disabled page gets no
control rather than a dead one. The reference ships a button
that takes focus, says its word and does nothing.
*/
<Control
class="pcd-action"
href={link}
type={link ? undefined : "button"}
hidden={link ? undefined : true}
data-pcd-view={link ? undefined : ""}
>
{actionLabel}
{/* Points the way out of the card. One glyph, no states to
keep in step. */}
<svg
class="pcd-action-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
</Control>
)}
</div>
</div>
</article>
<script>
/*
* The view control, and only when it is a button.
*
* Given an `href` the control is a link, none of this matches it, and the
* card is markup and CSS with no behaviour at all. Without one the button
* has nothing it can do by itself, so it is served `hidden` and revealed
* here — a control that cannot act should not be offered. The reference
* ships one regardless: focusable, labelled, inert.
*/
interface Instance {
destroy(): void;
}
function initProfileCard(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op, not a second set of listeners. */
if (root.dataset.pcdReady === "") return undefined;
const button = root.querySelector<HTMLButtonElement>("[data-pcd-view]");
if (!button) return undefined;
root.dataset.pcdReady = "";
const controller = new AbortController();
const { signal } = controller;
/* It works from here on, so it can be seen from here on. */
button.hidden = false;
button.addEventListener(
"click",
() => {
/*
* The card does not know what viewing means in the host's
* application — a route, a drawer, a panel — so it says what
* happened and lets the host decide. Bubbles and composed, so
* a listener on the page catches it.
*/
root.dispatchEvent(
new CustomEvent("profile-card:view", {
detail: { name: root.querySelector(".pcd-name")?.textContent ?? "" },
bubbles: true,
composed: true,
}),
);
},
{ signal },
);
function destroy() {
button!.hidden = true;
delete root.dataset.pcdReady;
controller.abort();
}
/*
* Astro replaces the document on a client-side navigation. Detach the
* listener, hide the control again, and clear the ready flag so a
* re-used element can boot again.
*/
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-profile-card]").forEach(initProfileCard);
/* Astro's own <script> is a deferred module, so the DOM is already parsed
by the time this runs. The readyState check is for the case where this
file is copied into a project that inlines it some other way. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />, and
never at all in one that does not. Registered once per module, so it
cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
<style>
.profile-card {
/*
* 320px is the width everything below was measured at. `width: 100%`
* under it is the fix for the reference, which is a fixed 320 and
* therefore runs off the side of a 280px screen.
*/
--pcd-width: 320px;
--pcd-pad: 8px;
/*
* The inset the name, bio and footer share. The reference uses 10/12
* of the frame, which is 25.3px here and leaves the footer 16px too
* narrow for its own contents — see the footer rule below.
*/
--pcd-inset: 12px;
--pcd-gap: 12px;
--pcd-name-size: 1.5rem;
--pcd-bio-size: 1rem;
--pcd-stat-size: 1rem;
--pcd-icon: 20px;
--pcd-dim: 0.6;
--pcd-faint: 0.4;
--pcd-fill: 0.1;
--pcd-fill-hover: 0.2;
--pcd-shadow: 0 1px 3px rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.2);
--pcd-ease: cubic-bezier(0.4, 0, 0.2, 1);
--pcd-duration: 150ms;
box-sizing: border-box;
display: block;
inline-size: 100%;
max-inline-size: var(--pcd-width);
/*
* Centred in whatever it is given, like every other capped component
* in this library. Without it a block with a max-width sits at the
* start of its container, which reads as a mistake rather than as a
* choice: in a 390px frame at 24px padding the card took the left 320
* and left 22px of nothing on the right. No effect at all in a
* container narrower than the cap, where the card already fills it.
*/
margin-inline: auto;
padding: var(--pcd-pad);
border-radius: var(--pcd-radius);
background: var(--pcd-bg);
color: var(--pcd-ink);
box-shadow: var(--pcd-shadow);
/* A system stack on purpose: a component in this library fetches
nothing, so it can never be waiting on a webfont. */
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
}
.profile-card *,
.profile-card *::before,
.profile-card *::after {
box-sizing: border-box;
}
/*
* A scoped reset, and it earns its place.
*
* Everything below sets type on classes, which beats inheritance — but it
* does not beat a host rule aimed at the bare element. A page carrying
* `h2 { padding: 0 40px }` silently gave the name 80px of padding and
* pushed the verified check to the far edge of the card; the font was
* right, the class rules were right, and the element was 80px too wide.
* Found by measuring the same card on two pages and getting two answers.
*
* `:where()` keeps this at the specificity of `.profile-card` alone, so
* the rules underneath still win on source order while a host's element
* selector loses.
*
* The `:global()` is load-bearing. Astro scopes every compound it can, and
* since it cannot put its attribute inside `:where()` it adds a second
* one beside it — `.profile-card[cid] [cid]:where(h2)` — which lands at
* three classes' worth of specificity and beats `.pcd-name[cid]` at two.
* The visible result was a 16px name and a button with no pill. Marking
* the inner half global leaves the scope on the ancestor alone, where it
* ties with the class rules and loses to them on source order.
*/
.profile-card :global(:where(h1, h2, h3, h4, h5, h6, p, span, a, button)) {
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
letter-spacing: inherit;
text-align: inherit;
text-transform: none;
text-decoration: none;
}
.profile-card :global(:where(button)) {
appearance: none;
}
/* ---- the portrait --------------------------------------------------- */
.pcd-frame {
aspect-ratio: 1 / 1;
overflow: hidden;
border: 2px solid var(--pcd-frame);
border-radius: var(--pcd-radius);
}
.pcd-photo {
display: block;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
object-position: 50% 50%;
}
/* No portrait passed: a tinted panel rather than a broken image. */
.pcd-photo--empty {
background:
linear-gradient(140deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.03));
}
/* ---- name, bio ------------------------------------------------------ */
.pcd-body {
margin-block-start: 24px;
padding-inline: var(--pcd-inset);
}
.pcd-nameline {
display: flex;
align-items: center;
gap: 8px;
}
.pcd-name {
margin: 0;
font-size: var(--pcd-name-size);
font-weight: 600;
line-height: 1.3333;
/* -0.05em. The name is the one thing set tight enough to notice. */
letter-spacing: -0.05em;
color: var(--pcd-ink);
}
.pcd-check {
flex: 0 0 auto;
inline-size: var(--pcd-icon);
block-size: var(--pcd-icon);
color: var(--pcd-accent);
}
.pcd-bio {
margin: 8px 0 0;
font-size: var(--pcd-bio-size);
line-height: 1.75;
color: var(--pcd-ink);
opacity: var(--pcd-dim);
}
/* ---- the footer ----------------------------------------------------- */
.pcd-footer {
display: flex;
align-items: center;
gap: var(--pcd-gap);
/*
* Wrapping is the safety valve, not the layout. At the default size
* everything sits on one line; a five-figure count or a longer control
* label sends the control to its own line instead of out of the card,
* which is what the reference does at every width it is rendered at.
*/
flex-wrap: wrap;
margin-block: 24px 0;
}
.pcd-stat {
position: relative;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: var(--pcd-stat-size);
font-weight: 600;
line-height: 1.75;
color: var(--pcd-ink);
}
.pcd-stat-icon {
flex: 0 0 auto;
inline-size: var(--pcd-icon);
block-size: var(--pcd-icon);
opacity: var(--pcd-faint);
}
/*
* An icon beside a figure is a label for it, and 40% of the ink is enough.
* An icon with no figure IS the item, and at 40% it reads as disabled — so
* a lone mark comes up to the same 60% the bio sits at. `:only-child`
* rather than a modifier class, because the markup already says it: the
* visually hidden label is absolutely positioned and out of flow, so an
* item with no figure has exactly one child in flow.
*/
.pcd-stat-icon:first-child:nth-last-child(2) {
opacity: var(--pcd-dim);
}
.pcd-stat-value {
font-variant-numeric: tabular-nums;
}
/* Read, not seen: the figure alone does not say what it counts. */
.pcd-a11y {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.pcd-action {
/* Pushed to the end rather than centred with the figures, so the row
reads as "counts, then the thing you press". */
margin-inline-start: auto;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
/*
* The reference's 24px, restored. It was 16 for as long as the control
* had to hold "Following" as well as "Follow" — 147px of label in a
* row with 126px for it. A single short label removes that constraint,
* so the measured value goes back.
*/
padding: 12px 24px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill) * 100%), transparent);
color: var(--pcd-ink);
font: inherit;
font-size: 1rem;
font-weight: 600;
line-height: 1.5;
text-decoration: none;
cursor: pointer;
transition: background-color var(--pcd-duration) var(--pcd-ease);
}
/* `display: inline-flex` above would otherwise beat the attribute, and the
control would be visible and dead before the controller runs. */
.pcd-action[hidden] {
display: none;
}
.pcd-action:hover {
background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill-hover) * 100%), transparent);
}
.pcd-action:focus-visible {
outline: 2px solid var(--pcd-accent);
outline-offset: 2px;
}
.pcd-action-icon {
flex: 0 0 auto;
inline-size: var(--pcd-icon);
block-size: var(--pcd-icon);
transition: translate var(--pcd-duration) var(--pcd-ease);
}
/*
* The arrow leans the way it is going. 2px, and the control keeps its own
* size — the glyph moves inside a box that does not, so nothing in the
* footer reflows and the card cannot change height on hover.
*/
.pcd-action:hover .pcd-action-icon,
.pcd-action:focus-visible .pcd-action-icon {
translate: 2px 0;
}
/* ---- preferences ---------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
/*
* One token, because every transition in the file resolves through it.
* Nothing here rests at zero opacity or zero size, so removing the
* motion leaves the card correct rather than blank.
*/
.profile-card {
--pcd-duration: 0.01ms;
}
/*
* The one exception to the token. Collapsing the duration would
* leave the arrow jumping 2px instantly on hover, which is the
* motion without the easing that made it read as motion. It is
* decorative, so here it simply does not happen.
*/
.pcd-action:hover .pcd-action-icon,
.pcd-action:focus-visible .pcd-action-icon {
translate: none;
}
}
@media (forced-colors: active) {
/* The card, the frame and the control are all fill and opacity, none
of which survive here, so each one takes a real border. */
.profile-card {
border: 1px solid CanvasText;
}
.pcd-frame {
border-color: CanvasText;
}
.pcd-action {
border: 1px solid CanvasText;
}
/* The lone-icon rule above is three selectors' worth of specificity, so
it has to be named here or a bare mark stays at 60% for exactly the
readers who asked for more contrast, not less. Found by measuring
under emulation, not by reading the cascade. */
.pcd-stat-icon,
.pcd-stat-icon:first-child:nth-last-child(2),
.pcd-bio {
opacity: 1;
}
}
</style>
===== END src/components/ProfileCard.astro =====
===== FILE (supporting): src/components/ProfileCard.md =====
# Profile card
A square portrait in a rounded frame, a name with an optional verified badge,
a short bio, and a footer row of marks beside a view control.
Catalog page: `/components/profile-card`
## Purpose
For a single person presented as an object you can act on — a directory
entry, a speaker, an author, a team page cell. The portrait carries most of
the card, which is why it is square and nearly the full width: at this size a
small round avatar would leave the card looking like a form.
Use something else for a list of people. One of these is 320px of vertical
commitment; six in a grid is a page about faces rather than about whatever
the faces are for.
## Files required
**`ProfileCard.astro`** — one file, the whole component.
- No imports at all, not even a type.
- No npm packages, no icon library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
component's own root element as a `--pcd-*` custom property.
- No webfonts. The four icons are inline SVG, so the card makes **no network
request of its own** beyond the portrait you pass it.
- The only assumption about the host is that Astro compiles `.astro` files.
`ProfileCard.md` — this file. Documentation, not required to run.
The catalog demo uses two portraits in `public/profile-card/`.
## Basic usage
```astro
---
import ProfileCard from "../components/ProfileCard.astro";
---
<ProfileCard
name="Ada Whitfield"
bio="Pre and post-operative breast care specialist."
image="/people/ada.jpg"
imageAlt=""
verified
stats={[
{ label: "Clinical care", icon: "cross" },
{ label: "Phone", icon: "phone" },
{ label: "Website", icon: "website" },
]}
/>
```
Footer items come in two shapes and you can mix them. Leave `value` out and
the item is the icon alone — a mark for something the person offers. Give it
one and the icon becomes a label for a figure:
```astro
stats={[
{ value: "1,219", label: "followers", icon: "followers" },
{ value: "172", label: "photos", icon: "photos" },
]}
```
No client directive, no wrapper, no init call — the component ships its own
module script and boots itself.
It is `width: 100%` up to a 320px cap and centred in whatever it is given, so
it sits in a grid cell or a column without further instruction.
## Props
Everything has a default, including the portrait.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `name` | `string?` | `"Ada Whitfield"` | Who the card is for. |
| `bio` | `string?` | a sentence | One or two lines under the name. `""` hides it. |
| `image` | `string?` | a placeholder | Portrait. Square; anything else is cropped to the centre. |
| `imageAlt` | `string?` | `""` | Empty marks the portrait decorative, which is right when the name beside it already identifies the person. |
| `verified` | `boolean?` | `false` | Show the check beside the name. |
| `verifiedLabel` | `string?` | `"Verified account"` | Its accessible name. |
| `stats` | `ProfileCardStat[]?` | cross + phone + website | Up to three items. A fourth is dropped rather than allowed to wrap. |
| `actionLabel` | `string?` | `"View"` | Control text. `""` drops the control entirely. |
| `href` | `string?` | — | Renders the control as a link. The component then ships no behaviour at all, and the control is the only form that works without JavaScript. |
| `headingLevel` | `"h2" \| "h3" \| "h4" \| "p"?` | `"h2"` | The name's element. `"p"` for a card inside a list where a heading would be wrong. |
| `background` | `string?` | `#000000` | The card's fill. |
| `ink` | `string?` | `#ffffff` | Name, footer items and control text. Every muted tone derives from it. |
| `accent` | `string?` | `#10b981` | The verified check and the focus ring. |
| `frameColor` | `string?` | `rgb(0 0 0 / 0.1)` | Hairline around the portrait. |
| `radius` | `string?` | `32px` | Card corners. The portrait uses the same. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. |
### One footer item
| Field | Type | What it does |
| :--- | :--- | :--- |
| `value` | `string?` | Already formatted — `"1,219"`, `"84.2k"`. Set in tabular figures. Omit it and the item is the icon alone. |
| `label` | `string` | What it is. Not shown; read by assistive technology. With no `value` beside it this is the **only** thing read, so it has to say something on its own. |
| `icon` | `"cross" \| "phone" \| "website" \| "followers" \| "photos" \| "posts" \| "none"?` | Which glyph sits before it, or instead of it. |
A lone icon is drawn at 60% of the ink rather than 40%. Beside a figure the
icon is a label for it and can recede; on its own it *is* the item, and at
40% it reads as disabled.
```ts
import type { ProfileCardStat } from "../components/ProfileCard.astro";
```
### A light card
Every colour is a prop, so the light version is four of them:
```astro
<ProfileCard
background="#ffffff"
ink="#0a0a0a"
frameColor="#e5e7eb"
/>
```
## Behaviour
The control has one state. It is an action, not a switch: nothing about it
changes when you press it, because pressing it is meant to take you somewhere.
| Input | What happens |
| :--- | :--- |
| Click the control | With `href`, it navigates. Without, a `profile-card:view` event fires on the root. |
| <kbd>Enter</kbd> | The same, from the keyboard. <kbd>Space</kbd> too on the button form, as buttons do. |
| <kbd>Tab</kbd> | Reaches the control. It is the only tab stop. |
| Hover or focus | The fill goes from 10% to 20% of the ink and the arrow leans 2px the way it points, both over 150ms. |
### Wiring the view
If the action is a navigation — and it usually is — pass `href`. The control
becomes an anchor, the script never matches it, the card is pure markup and
CSS, and it works with JavaScript switched off:
```astro
<ProfileCard name="Ada Whitfield" href="/people/ada" />
```
Without one you get a button, for the case where viewing means opening a
panel rather than leaving the page. The card does not know which, so it says
it was pressed and leaves the rest to you. The event bubbles and is composed:
```js
document.addEventListener("profile-card:view", (event) => {
openProfilePanel(event.detail.name);
});
```
### The glyph
One arrow, and it moves 2px on hover inside a box that does not resize, so
the footer cannot reflow and the card cannot change height under the pointer.
Under reduced motion the nudge does not happen at all — collapsing the
duration token would leave it jumping the 2px instantly, which is the motion
without the part that made it read as motion.
## Accessibility
- The name is a real heading, `h2` by default and settable, so the card slots
into a page outline instead of breaking it.
- **The portrait is decorative by default.** Its `alt` is empty because the
name sits right beside it; a screen reader announcing "photo of Ada
Whitfield" immediately before "Ada Whitfield" is noise. Pass `imageAlt` when
the picture carries something the name does not.
- The verified check is a labelled `img` role, not a decorative flourish —
"verified" is information.
- **Each footer item says what it is.** `1,219` on its own means nothing and
a bare icon means less; the word is in the markup, visually hidden, so it is
read rather than hovered for. The reference shows a bare number beside an
unlabelled icon. Note what this does and does not buy you: an icon-only item
announces its label and nothing else, so `"Phone"` tells a screen-reader user
that this person has a phone, not what the number is. If the marks are meant
to be contact details, put them in the bio or give the card an `href` to a
page that has them.
- The control is a real `<button>`, or a real `<a>` when it has somewhere to
go — never a div, and never a link without an `href`. It has one state and
no ARIA, because native semantics already say everything true about it.
The arrow is `aria-hidden`; the label carries the meaning.
- Focus ring is the accent at 2px with 2px of offset, outside the pill.
- Figures are set in tabular numerals, so a count that updates does not
jiggle the row.
- Under `forced-colors: active` the card, the frame and the control each take
a real border, since all three are otherwise fill and opacity.
## Reduced motion
Under `prefers-reduced-motion: reduce` the one duration token collapses to
`0.01ms`, so the fill change becomes a cut, and the arrow's hover nudge is
switched off rather than made instant. Nothing rests at zero opacity or zero
size, so the card is correct rather than blank.
## Without JavaScript
The card renders in full: portrait, name, badge, bio and every footer item.
**The button is not rendered.** Without an `href` the only thing the control
can do is report the press, and it cannot do that without script — so it is
served `hidden` and revealed by the controller. A control that cannot act
should not be offered. The reference ships one regardless: focusable,
labelled, inert.
With `href` set there is nothing to hide. The control is an anchor, it works
with no script at all, and this whole paragraph stops applying — which is the
better way to use the component if the action really is a navigation.
## Content length
- **Name** — up to about 18 characters on one line at 320px. Longer wraps, and
the badge follows the last line.
- **Bio** — 8 to 14 words. Two lines is the design; three is fine, four starts
to unbalance the portrait above it.
- **Footer items** — three bare icons fit beside the control with room to
spare (84px of the row's 280). Figures are four or five characters: `84.2k`
fits; `1,204,338` sends the control to its own line.
## Sizing
`width: 100%` up to a `320px` cap, so it fills a narrower column and never
exceeds the width it was designed at. Everything inside is fixed, because a
320px card has one size.
`margin-inline: auto` centres it in any container wider than the cap, which is
what every other capped component in this library does. In a container
narrower than 320px it has no effect — the card already fills it. If you want
the card hard against the start of its container instead:
```css
.my-column .profile-card {
margin-inline: 0;
}
```
The footer wraps as a safety valve rather than as a layout: at 320px the
footer items and the control sit on one line, and a longer count or label
sends the control to its own line instead of out of the card.
Retune from CSS without touching the file:
```css
.my-grid .profile-card {
--pcd-width: 280px;
--pcd-radius: 12px;
--pcd-inset: 20px;
--pcd-duration: 0ms;
}
```
## Differences from the reference
Rebuilt from a live page, measured rather than copied. All 30 values compared
at 1440px landed identical: the 320px card at 8px padding and a 32px radius,
the 304px square frame with its 2px hairline over a 300px portrait, the 24px
body offset, the name at 24px/32px semibold at -1.2px tracking beside a 20px
emerald check, the bio at 16px/28px, and a 48px pill at 12px/24px padding with
a 150ms colour transition. What differs:
- **The copy and the portrait are original.** The reference uses a photograph
of a named real person; nothing of it, or of its text, is reproduced. The
demo portraits are abstract figures drawn for this library.
- **The footer does not overflow.** The reference centres a row whose contents
are 269px wide inside a 253px column, so the figures start 8px left of the
bio and the control ends 8px right of it — at every width it renders at.
Here the shared inset is 16px rather than 10/12ths, which gives the row the
room it needs, and wrapping catches the rest.
- **The card shrinks.** The reference is a fixed `320px`, so at a 280px
viewport it runs off the side of the screen. This is `width: 100%` under a
320px cap.
- **The control does something**, and is not rendered when it cannot. The
reference's is a static demo: a button with no state and no destination.
- **Footer items are labelled** for assistive technology, and figures are set
in tabular numerals. The default card carries marks rather than counts: a
profile card for a practitioner says what they do, not how many followers
they have.
- **It is immune to the host's element styles.** A page with
`h2 { padding: 0 40px }` moved the reference's name — and moved this one
too, until a scoped reset was added. See the note in the stylesheet: the
`:global()` around it is load-bearing.
- The reference sets its text in Geist, a webfont. This uses the system stack,
because a component in this library fetches nothing.
===== END src/components/ProfileCard.md =====
===== FILE (required): public/profile-card/portrait-1.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" role="img">
<defs>
<linearGradient id="p1bg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#efece6"/><stop offset="1" stop-color="#dbd6cd"/></linearGradient>
<linearGradient id="p1sh" x1="0.05" y1="0.1" x2="0.95" y2="1">
<stop offset="0" stop-color="#f7ccd6"/><stop offset="0.5" stop-color="#f5e3ba"/>
<stop offset="1" stop-color="#c7dfd6"/></linearGradient>
</defs>
<rect width="800" height="800" fill="url(#p1bg)"/>
<path d="M356 420h88v150h-88z" fill="#dcc7b6"/>
<path d="M130 800c0-150 121-272 270-272s270 122 270 272z" fill="url(#p1sh)"/>
<circle cx="400" cy="300" r="152" fill="#ecdccf"/>
<path d="M248 296c0-88 68-156 152-156s152 68 152 156c0 26-6 50-16 70 6-40 4-84-20-108-30-30-84-34-116-34-52 0-96 14-124 44-24 26-32 62-28 98-6-22-10-44-10-70z" fill="#241d22"/>
<path d="M252 286c-14 46-10 96 8 132-30-26-42-74-30-116zM548 286c14 46 10 96-8 132 30-26 42-74 30-116z" fill="#241d22"/>
</svg>
===== END public/profile-card/portrait-1.svg =====
===== FILE (required): public/profile-card/portrait-2.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" role="img">
<defs>
<linearGradient id="p2bg" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#2a3640"/><stop offset="1" stop-color="#141b21"/></linearGradient>
<linearGradient id="p2sh" x1="0" y1="0.1" x2="0.9" y2="1">
<stop offset="0" stop-color="#55798a"/><stop offset="1" stop-color="#2d454f"/></linearGradient>
</defs>
<rect width="800" height="800" fill="url(#p2bg)"/>
<circle cx="596" cy="176" r="58" fill="#e6b269" opacity="0.8"/>
<path d="M356 420h88v150h-88z" fill="#b8967e"/>
<path d="M130 800c0-150 121-272 270-272s270 122 270 272z" fill="url(#p2sh)"/>
<path d="M400 528c-26 0-51 3-74 8l74 70 74-70c-23-5-48-8-74-8z" fill="#e8e2d8" opacity="0.92"/>
<circle cx="400" cy="300" r="152" fill="#d4b096"/>
<path d="M256 272c22-78 82-132 144-132s122 54 144 132c-30-46-84-72-144-72s-114 26-144 72z" fill="#1b1512"/>
</svg>
===== END public/profile-card/portrait-2.svg =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
NOT production ready in the library.
Verified 2026-09-18 at 1440, 1024, 768, 480, 390, 360, 320, 280px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source has CHANGED since verification. Treat every result above as expired.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.