Everything you need to know about defining rows, columns, and placing items in a grid container.
Read article →Home / Snippets / UI Components /
Interactive card (click area)
Make an entire card surface clickable using a ::after pseudo-element stretched with inset: 0, while keeping secondary links independently tappable via z-index.
Compositor-only animations using transform and opacity for buttery 60 fps motion with zero layout cost.
Read article →Focus trapping, aria-modal, and keyboard handling patterns for dialog elements that work for everyone.
Read article →Quick implementation
.card {
position: relative; /* establishes stacking context */
border-radius: 0.5rem;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px oklch(0 0 0 / 0.35);
}
/* Stretch the primary link to cover the whole card */
.card__primary-link::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
/* Focus ring on the pseudo-element */
.card__primary-link:focus-visible::after {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Lift secondary links above the stretched ::after */
.card__secondary-link {
position: relative;
z-index: 1;
}
Prompt this to your LLM
Includes role, constraints, two framework variants, and edge cases to handle.
You are a senior frontend engineer building accessible card UI components.
Goal: Make an entire card surface clickable with a single primary link,
while secondary links inside the card remain independently clickable —
no JavaScript, no wrapping the card in an <a> tag.
Technical constraints:
- Give .card position: relative to establish a stacking context.
- On the primary link, set ::after { content: ""; position: absolute; inset: 0; }
so the pseudo-element covers the full card area and receives clicks.
- Add border-radius: inherit to the ::after so the click area follows
the card's rounded corners exactly.
- Style .card__primary-link:focus-visible::after with an outline so
keyboard users get a visible full-card focus ring.
- For any secondary links (share, tag, author), set position: relative
and z-index: 1 so they sit above the stretched ::after in paint order.
- Use oklch() for all color values — no hex or rgba.
- Use CSS custom properties (var(--card), var(--text), var(--accent)) for theming.
- Add a hover state on .card with transform: translateY(-3px) and
box-shadow to give elevation feedback.
Framework variant (pick one):
A) Vanilla HTML/CSS — output the .card structure and stylesheet.
B) React component — accept title, description, href, and secondaryLinks
(array of { label, href }) as props; render the correct markup and
apply the CSS module or Tailwind classes.
Edge cases to handle:
- If the card contains a button (e.g. a bookmark toggle), give it
position: relative; z-index: 1 just like secondary links.
- Avoid nesting block elements inside <a>, which is invalid HTML5
for non-transparent content — the ::after pattern sidesteps this.
- Ensure the card is not keyboard-reachable as a whole (no tabindex on
.card itself) — the primary link <a> already handles that.
- Test with VoiceOver/NVDA: the screen reader should announce the link
text (e.g. "Read article") not the entire card text content.
Return the HTML markup and CSS (or a React component if variant B).
Why ::after stretching beats wrapping in <a>
The naive approach to a fully clickable card is to wrap the entire card in an <a> element. It works visually, but HTML5 forbids nesting interactive content inside <a> — so any secondary link, button, or form control inside the card becomes invalid markup, and browsers may silently break the nested elements. Screen readers also behave unpredictably: some announce every text node inside the outer <a> as one enormous link label.
The ::after stretch pattern solves both problems. The primary link stays a small, semantically correct anchor whose text is the actual link label (e.g. "Read article"). Its ::after pseudo-element is given position: absolute; inset: 0, which expands its hit target to cover the entire position: relative card. Because the pseudo-element is part of the link, clicking anywhere on the card activates it — but the markup stays valid, and the link label remains concise and meaningful.
The z-index trick for secondary actions
The stretched ::after sits in the stacking order above the card's background but — by default — also above any sibling elements rendered after the primary link. Secondary links, share buttons, and tags would become un-clickable because the pseudo-element intercepts all pointer events in the card's bounding box.
The fix is to give any secondary interactive element position: relative and z-index: 1. This lifts them above the ::after layer without affecting document flow. No JavaScript, no pointer-events hacks — just a single CSS rule per secondary element. The same rule applies to any <button> inside the card (bookmark toggles, like counters, etc.).
One detail matters: the card itself must have position: relative (or any non-static position) so that the ::after's inset: 0 is computed relative to the card's own box, not a distant ancestor. Without it, the pseudo-element could stretch to fill the viewport.
Accessibility
Screen readers do not read ::after pseudo-element content as interactive — they surface the real <a> element. VoiceOver and NVDA announce only the link's text content ("Read article", "View product"), not the card's full text. This is the correct, expected behaviour: the pseudo-element is a visual/pointer affordance, invisible to the accessibility tree.
Keyboard navigation also works correctly. Pressing Tab moves focus to the primary link anchor; pressing Enter follows it. The focus ring should be drawn on the ::after pseudo-element (using :focus-visible::after { outline: … }) so sighted keyboard users see a full-card highlight rather than a thin ring around just the link text — matching what mouse users experience with the hover state.
Secondary links are independently focusable and announced with their own labels. Because they have position: relative; z-index: 1, they receive pointer events normally and appear correctly in the tab order based on their position in the DOM.