Skip to content
Twenty One Media
webSeptember 24, 2026

The Dimming That Doesn't Live in React State

When a user picks a question in the Prism demo, most of the evidence cards dim. Two stay bright: the ones that are direct evidence for that answer. The rest fade to 40% opacity until a new question is chosen or the demo resets.

We could have written that in React. The naive version looks like this:

className={`prism-landscape-card ${related ? "is-related" : ""} ${selected === card.id ? "is-open" : ""}`}

Then in CSS, .is-related { opacity: 1 } and .prism-landscape-card { opacity: .4 } when a question is active. It works. It also means React is computing a new string for every card on every state change, and your JSX starts to read like a template engine.

We did something different. The cards carry data attributes that tell CSS what to do:

<button
  data-related={related}
  data-open={selected === card.id}
  ...
>

And the grid's parent gets one:

<div className="prism-landscape-grid" data-active={active !== null}>

CSS takes it from there:

.prism-landscape-grid[data-active="true"]
  .prism-landscape-card:not([data-related="true"]):not(:focus-visible):not([data-open="true"]) {
  opacity: .4;
}

Read that selector as: "when a question is active, find every card that is not related evidence, not keyboard-focused, and not currently open, then dim it." Four conditions, one rule, no JavaScript.

The related card keeps full opacity and lifts:

.prism-landscape-card[data-related="true"],
.prism-landscape-card[data-open="true"],
.prism-landscape-card:focus-visible {
  transform: perspective(1000px) rotateX(0) rotateY(0) rotateZ(0) translateY(-5px);
  border-color: var(--card-color);
  opacity: 1;
}

Three different entry paths, same visual treatment. A card can earn full opacity by being related evidence, by being the one currently open, or by receiving keyboard focus. CSS handles all three with one declaration block.

The dragging cursor follows the same pattern. When a drag starts, the viewport gets data-dragging="true":

event.currentTarget.dataset.dragging = "true";

And CSS switches the cursor:

.prism-landscape-viewport[data-dragging="true"] { cursor: grabbing; }

One attribute write, no state update, no re-render.

The reduced-motion block also reads cleanly. Instead of resetting individual className-based states, it resets the entire data-attribute surface:

@media (prefers-reduced-motion: reduce) {
  .prism-landscape-card,
  .prism-landscape-card[data-related="true"],
  .prism-landscape-card[data-open="true"],
  .prism-landscape-card:hover,
  .prism-landscape-card:focus-visible {
    transform: none;
  }
}

Every visual state, zeroed out in one block. Adding a new data attribute state later doesn't require touching this rule.

The pattern works because data attributes are part of the DOM, not an abstraction over it. CSS can already select on them. React can set them through dataset or JSX props. The state machine stays in one place, React, while the visual consequences stay in another, CSS. Neither needs to know the details of how the other works.

The one thing to watch: data-related={related} passes a boolean in JSX, which renders as the string "true" or "false" on the DOM element. CSS attribute selectors match strings, so [data-related="true"] works. [data-related] would match both. Know which one you want.