DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The example cards on our landing page take a highlight function as an argument, so each note underlines its own words

The first thing you see on nakodo.app is a field guide page with three example creators on it. Hover any of the three and a note opens beside it explaining what the product saw in that entry, with the part it is talking about underlined.

That last clause is the whole problem. A picture of a product tells you nothing about which part of it matters, so the note has to be anchored on the thing it explains. And the thing it explains is different every time: in the first entry it is three words inside a sentence, in the third it is a chip that says no email was published.

This is how 173 lines of sample-entries.tsx solve that, and the two things I found while writing it down.

The data takes the renderer, not the other way round

The usual shape for this would be a note with a field name in it, or a string with markers in it that the renderer parses. Ours makes the entry a function of a mark callback:

type Entry = {
  no: number;
  name: string;
  handle: string;
  score: number;
  why: (mark: (text: React.ReactNode) => React.ReactNode) => React.ReactNode;
  stats: string;
  contacts: (mark: (text: React.ReactNode) => React.ReactNode) => React.ReactNode;
  note: string;
};
Enter fullscreen mode Exit fullscreen mode

An entry then marks whatever it likes, wherever it sits:

{
  no: 147,
  name: "Open Rate Lab",
  handle: "@openratelab",
  score: 91,
  why: (mark) => <>Viewers {mark("ask which tool to pick")} under most videos</>,
  stats: "48K subscribers · 21K avg views · 4 uploads a month",
  contacts: () => (<><ContactChip state="found" title={null} />{social("x")}<ThreadStatusBadge status="handed_off" closedReason={null} /></>),
  note: "Audience asks which tool to pick. Buying questions under the videos are the best sign a creator can sell for you. They said yes, so Nakodo introduced you.",
}
Enter fullscreen mode Exit fullscreen mode

and the entry that has no email to show marks a component rather than a phrase, and takes no mark in why at all:

{
  no: 163,
  score: 30,
  why: () => "Spreadsheet templates for small teams",
  contacts: (mark) => <>{mark(<ContactChip state="none" title={null} />)}</>,
  note: "Email not found. This channel hasn't published one and we never guess addresses, so Nakodo leaves them be. It scores 30 of 100: a related topic, but a small audience.",
}
Enter fullscreen mode Exit fullscreen mode

Because mark takes and returns React.ReactNode, there is no second code path for "mark a component" versus "mark some words". The highlight is a wrapper, and anything that can be a child can be wrapped.

The alternative I had in mind first was a string with a token in it, parsed into a span. It would have worked for the sentences and not for the chip, and it would have put a parser between the copy and the screen for no reason. A function is a parser you do not have to write.

The renderer builds mark per card

mark is created inside the component, closed over that card's state:

function HeroEntry({ entry, first, children }) {
  const [open, setOpen] = useState(false);
  // The note is anchored on the part it explains, wherever that sits in the card.
  const mark = (text: React.ReactNode) => (
    <Tooltip open={open}>
      <TooltipTrigger asChild>
        <span
          data-marked={open || undefined}
          className="rounded-sm underline decoration-transparent decoration-dotted decoration-1 underline-offset-4 transition-colors data-marked:decoration-cyano-600"
        >
          {text}
        </span>
      </TooltipTrigger>
      <TooltipContent side="top" sideOffset={6} updatePositionStrategy="always" data-note="card" className="pointer-events-none max-w-[280px]">
        {entry.note}
      </TooltipContent>
    </Tooltip>
  );
Enter fullscreen mode Exit fullscreen mode

Two details in that span. The underline is always there, with decoration-transparent, so marking it changes a colour rather than adding a line and shifting text by a pixel. And data-marked={open || undefined} uses undefined rather than false, because React omits the attribute entirely for undefined while false would render data-marked="false" and match a [data-marked] selector.

Why the tooltip is controlled

Radix's tooltip is perfectly good uncontrolled, and three of its behaviours are wrong for this. All three reasons are in the file, which is the only reason I can still explain them:

// The card alone decides when the note shows. Radix would also close it when
// the page scrolls, and whenever a tooltip opens, including its own as the
// pointer crosses the marked words, while the pointer is still on the card.
Enter fullscreen mode Exit fullscreen mode

The last one is the interesting one. The trigger is the marked phrase, but the hover target is the whole card, so the pointer crossing the phrase would open "its own" tooltip and close the one already open, which reads as a flicker in the middle of a sentence. Passing open and never passing onOpenChange takes Radix out of the decision entirely.

// Tracked every frame, so the note keeps up with the page when it is
// scrolled while the pointer rests on a card. Checking only on scroll
// events leaves it a frame behind and it judders.
updatePositionStrategy="always"
Enter fullscreen mode Exit fullscreen mode
// The note can't take the pointer (globals.css), so moving over it
// still counts as hovering the card underneath and it stays open.
data-note="card"
Enter fullscreen mode Exit fullscreen mode

pointer-events-none on a tooltip sounds like a bug until you remember what opens it. The card is the trigger; the note floats over the card; without pointer-events-none the note steals the pointer from its own reason for existing and closes itself.

Pointer types, because hover is not universal

<li
  onPointerEnter={(e) => e.pointerType === "mouse" && setOpen(true)}
  onPointerLeave={(e) => e.pointerType === "mouse" && setOpen(false)}
  // Touch has no hover: a tap anywhere on the card shows the note.
  onPointerDown={(e) => e.pointerType !== "mouse" && setOpen(true)}
  onFocus={() => setOpen(true)}
  onBlur={() => setOpen(false)}
>
Enter fullscreen mode Exit fullscreen mode

One element, two input models, no media query and no matchMedia("(hover: hover)"). The pointerType on the event is the honest signal: it describes the device that generated this event, not the device the browser thinks you own, which matters on hybrids where both are true. Closing is handled from the other side the same way, on the tooltip rather than the card:

onEscapeKeyDown={() => setOpen(false)}
onPointerDownOutside={(e) => e.detail.originalEvent.pointerType !== "mouse" && setOpen(false)}
Enter fullscreen mode Exit fullscreen mode

A mouse user leaving the card already triggers onPointerLeave, so the outside-press handler only has work to do for touch.

Finding: two of those handlers can never fire

I measured the rendered plate on the live page rather than reading the component, which is how this turned up. The <ol> and its three entries are 8,553 bytes of HTML containing:

3  <li>
3  spans with data-marked
0  <a>
0  <button>
0  tabindex
Enter fullscreen mode Exit fullscreen mode

Nothing inside a card is focusable. No link, no button, no tabindex, and the card itself is a plain <li>. So onFocus and onBlur are unreachable code: a keyboard user tabs straight past all three entries without ever opening a note.

What saves it is that the note does not depend on the tooltip to be readable:

{/* The note is a hover affordance, so it is read out as part of the entry instead. */}
<p className="sr-only">{entry.note}</p>
Enter fullscreen mode Exit fullscreen mode

The note text is in the DOM once, as visually hidden text inside the entry, and the floating tooltip is mounted on demand by Radix. A screen reader reads each entry with its explanation attached, in order, with no interaction at all, which is better than making people hunt for a hover target. The ContactChip does the same thing at a smaller scale: its icon state is backed by an sr-only "published" or "not published", so the three chips on the plate are readable without colour or shape.

So the behaviour is defensible and the code is not: onFocus and onBlur are either dead lines to delete, or a promise to make the card focusable with tabIndex={0} and a visible focus ring. I will delete them, because the hidden note is the better answer for a decorative example.

Finding: the mock is made of the real components

The entries are rendered with ScoreChip, ContactChip, ThreadStatusBadge, CatalogueNo and the same hatched avatar placeholder the app uses, imported from the same places the signed-in pages import them from. The only invented thing is the data, and the file says so in a comment: "Example creators for the landing plate, drawn with the same components as the app. The names are made up."

That is the part I would keep in any product I build next. A hand-built landing page mock is a screenshot that cannot be re-rendered: it drifts the first time a chip changes shape, and nobody notices because nobody runs the marketing page through a design review. Building the mock out of the product's own components makes the drift impossible, and it costs one file of fake data. The flow the plate is standing in for is described in words on how it works, and the plate is the part that makes it look like a thing rather than a claim.

There is one piece of pure decoration in there, for the record: a second bird stands on the rule under the first entry, offset from the left so the gap holds at every width, pecking half a cycle after the first one:

className="illo-peck ... left-44 ... [--peck-delay:4.7s] sm:left-88"
Enter fullscreen mode Exit fullscreen mode

The peck is a 7 second cycle with a default --peck-delay of 1.2s, so 4.7s is exactly 3.5 seconds later, half a cycle, and the two never peck together. The whole animation lives inside @media (prefers-reduced-motion: no-preference), so for anyone who asked for less motion both birds simply stand there. Nobody asked for any of it. It is the kind of thing a field guide should have.

Top comments (0)