Skip to content

Clickable Container with Child Controls

When to use

Use this pattern when an entire container should be clickable as a primary action, but the container also holds secondary interactive controls that must remain independently clickable. Cards and list rows are common examples, but the pattern applies to any container that needs to be both clickable and contain interactive elements within it.

The problem

The naive solution is to nest the secondary controls inside the primary one:

html
<!-- ❌ Never do this -->
<a href="/item/">
  <article>
    <h3>Item title</h3>
    <p>Item description</p>
  </article>
  <button type="button">★ Favorite</button>
</a>

This is invalid HTML because interactive elements cannot be nested inside one another. The root issue is semantic ambiguity: when a user activates an interactive element that is inside another interactive element, there is no clear answer for which one should respond. This ambiguity is especially harmful for assistive technologies. Screen readers may not announce the inner controls at all, keyboard navigation can skip them, and click targets can conflict. People who depend on these technologies can be locked out of functionality entirely.

Both approaches below solve this problem by doing three things:

  1. All interactive elements are siblings — descendants of the container, but never nested inside one another
  2. The primary action is made to fill the two-dimensional space of the container, so the entire container acts as its click target
  3. Secondary actions are given a higher z-index than the primary action so they remain clickable on top

The two approaches differ only in how they achieve step 2: one uses a pseudo-element with absolute positioning, the other uses CSS Grid placement. Which you choose is an implementation detail, likely driven by adjacent layout considerations.

Approach 1: Pseudo-element breakout

The primary action element ends up with two rectangles in the box model: its principal box wraps only its own label text (e.g., a link reading "Vintage Denim Jacket — $85"), while a ::before pseudo-element is absolutely positioned to fill the entire container, which is given position: relative for this exact purpose. Because the link itself has position: static, its ::before escapes the link's principal box and stretches to the container's edges instead.

The result: the link's principal box wraps only the visible label content, but its ::before box covers the whole container, making the entire area a click target. Secondary actions float above the ::before via z-index, intercepting their own clicks.

Structure

html
<li>
  <a href="/items/42/">
    Vintage Denim Jacket — $85
  </a>
  <button type="button">★ Favorite</button>
  <button type="button">Add to cart</button>
</li>

All interactive elements are siblings inside the li. No nesting.

Styling

css
/* Container: becomes the bounding box for the pseudo-element */
li {
  position: relative;
}

/* Primary action: static so its ::before escapes its own box */
li > a {
  position: static;
}

/* Pseudo-element: fills the container, catches all clicks */
li > a::before {
  content: "";
  position: absolute;
  inset: 0;
}

/* Secondary actions: elevated above the pseudo-element */
li > button {
  position: relative;
  z-index: 1;
}

How it works

  1. li gets position: relative, establishing the bounding rectangle
  2. li > a stays position: static — this is the critical move, because it means the link's ::before pseudo-element won't be contained by the link's own box
  3. li > a::before gets position: absolute; inset: 0 — with no positioned parent to cling to (the link is static), it climbs up to the li and fills it entirely, making the whole row a click target for the link
  4. li > button gets position: relative; z-index: 1 — this lifts it above the pseudo-element layer so clicks on secondary controls reach them, not the primary action underneath

The stacking order from bottom to top: list item background → primary action's pseudo-element (full-area click surface) → secondary action buttons.

Approach 2: CSS Grid placement

CSS Grid provides another way to achieve the same effect. Instead of pseudo-elements and absolute positioning, the primary action spans the full grid area and secondary actions are placed in the same grid cells at a higher z-index.

Structure

html
<li class="row">
  <a class="primary" href="/items/42/">
    <span>Vintage Denim Jacket — $85</span>
    <time>Listed 2 hours ago</time>
  </a>
  <button class="secondary" type="button">★ Favorite</button>
  <button class="secondary" type="button">Add to cart</button>
</li>

All interactive elements are siblings — no nesting.

Styling

css
/* Container: grid with columns for content and actions */
.row {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-rows: 1fr;
}

/* Primary action: spans the full grid area */
.primary {
  grid-column: 1 / -1;
  grid-row: 1;
}

/* Secondary actions: placed in the last column, above primary */
.secondary {
  grid-column: 2;
  grid-row: 1;
  z-index: 1;
}

How it works

  1. .row establishes a grid with two columns: one flexible (1fr) for the main content and one auto-sized for secondary controls
  2. .primary spans all columns (grid-column: 1 / -1) on the first row — this makes the entire row a click target for the primary action
  3. .secondary is placed in the last column (grid-column: 2) and the same row (grid-row: 1), then elevated above the primary action with z-index: 1 so clicks on it reach the secondary control, not the primary action underneath

The grid approach avoids the position: static / position: absolute trick entirely. Both the primary and secondary elements participate in the same grid layout, and the overlap is achieved purely through grid placement.

Note that subgrid is not required for this technique. It becomes useful when you have a list of these containers and elements within each container need common alignment across rows — for example, ensuring the title column and timestamp column line up across sibling list items despite varying content lengths. In that case, add subgrid to the primary action:

css
.primary {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
  grid-row: 1;
}

This forces the primary action's children to inherit the parent's column tracks, keeping columns aligned across rows even with inconsistent or hostile content lengths.

Choosing between the approaches

The most important difference is how the primary action element is shaped in the box model.

With pseudo-element breakout, the primary action element has two rectangles: its principal box (wraps only the visible label content) and its ::before box (fills the entire container). Both rectangles belong to the same element. The principal box is what you see as the link text; the ::before box is the click surface covering the container.

With CSS Grid placement, the primary action element has a single box that fills the entire container. No pseudo-element is involved — the element itself is the full-area click target.

This matters for hover and focus styles. In both approaches, :hover on the primary action fires across the full container area — the ::before is part of the link element, so hovering over it triggers :hover on the link. But CSS box-model properties like background, box-shadow, and outline only render on the element's principal box, not on its pseudo-elements.

With the grid approach, the element has one box filling the container, so styling it directly produces container-wide hover visuals:

css
/* Grid: the element's box fills the container */
.primary:hover {
  background: var(--hover-bg);
}

With the pseudo-element approach, the element's principal box wraps only its label content, so a:hover { background: ... } only colors the principal box. To get a container-wide hover visual, style the ::before box or the container:

css
/* Pseudo-element: style the large ::before box on hover */
li > a:hover::before {
  background: var(--hover-bg);
}

/* Or style the container (hover propagates to ancestors) */
li:hover {
  background: var(--hover-bg);
}

The same applies to focus outlines — in the grid approach, the element's focus ring naturally traces the container boundary because its one box fills it. In the pseudo-element approach, the focus ring traces the principal box, so you need :focus-within on the container for a container-wide focus visual.

Other trade-offs

Pseudo-element breakout is the more established technique with broader browser support — it works in any browser that supports position: absolute and ::before, which is effectively all of them. However, it consumes the primary action's ::before pseudo-element, making it unavailable for other styling. It also depends on a specific stacking of position values (static on the link, relative on the container, relative with z-index on secondary controls), which can be fragile — any ancestor that introduces a new stacking context or positioned element can break the layout. Because the primary action's principal box wraps only its label content, it works well when you want a visible, distinct click target that has a large hit area via its ::before.

CSS Grid placement expresses the overlap through grid placement rather than positioning hacks, which can make the intent clearer in the code. The primary action's pseudo-elements remain available for other styling. Grid placement is also less susceptible to stacking context accidents from ancestor elements. The basic technique works in any browser that supports CSS Grid (all modern browsers). subgrid is only needed when you have a list of these containers and elements within each need common alignment across rows — and has narrower browser support (available in all major browsers since late 2023). Because the primary action element fills the container, it's the better choice when you want the entire container to look and behave as a single clickable surface with no distinct visible primary click target.

HTML table rows

Neither technique works with <tr> elements. The pseudo-element breakout requires position: relative on the container, but position: relative on a <tr> is unreliable — Firefox has never supported it, and other browsers handle it inconsistently. The CSS Grid approach requires display: grid on the container, but setting display: grid on a <tr> replaces display: table-row entirely, breaking the table layout.

If the data genuinely needs semantic table structure (spreadsheets, financial data, comparison tables), neither CSS approach applies. The interactive controls within each row remain individually accessible — users can tab to links and buttons inside the row — but you cannot make the row itself act as a single expanded click target the way these approaches do for <div> or <li> containers. JavaScript click delegation on the row can add a mouse convenience (clicking empty space in the row activates the primary link), but this is not a substitute for the pattern described above: it is not keyboard-accessible, not communicated to assistive technologies, and not visible as an affordance. It should be treated as a progressive enhancement for pointer users only, not as a primary interaction model.

If the data is a list that just happens to look tabular (product listings, search results), use <div> elements with ARIA table roles (role="table", role="row", etc.) instead of native <table> markup. This gives full CSS control — both approaches work — while preserving table semantics for assistive technologies.

Shared techniques

Focus and hover styles

Use :focus-within and :focus-visible on the container to style the row when the primary action receives focus, reinforcing the visual illusion that the whole row is the primary action:

css
li:focus-within {
  outline: 2px solid currentColor;
  outline-offset: -2px;
}

li > a:focus-visible {
  outline: none; /* row outline handles the visual */
}

Defensive z-index

To prevent stacking context accidents when the container's z-index changes, derive the secondary action's z-index from the container's value:

css
li {
  --z-index: 0;
  z-index: var(--z-index);
}

li > button {
  z-index: calc(var(--z-index) + 1);
}

Accessible name length

A secondary benefit: because the primary action wraps only its own label text (not the entire container's content), its accessible name stays concise. A screen reader announces just the link text — not every child element flattened into one long string, which is what happens when you wrap an entire card in a single anchor tag.

Cautions

  • The expanded click area increases risk of accidental activation. Avoid using this for destructive or irrevocable actions without a confirmation step.
  • Consider users with hand tremors, low vision, or eye-tracking input where precise targeting is more difficult.

Progressive enhancement

Without CSS, the result is a plain list of links and buttons — fully functional with no layout tricks required.

References