Controls and affordance

Controls and affordance

A control must say what it will do

A control that OPENS something and a control that DOES something have to be distinguishable at a glance. The disclosure caret is what does that work.

A primary fill cannot do it, because it is the same fill the action buttons use. If a dropdown trigger and a submit button look identical, the only way to find out which is which is to press it.

Found on the extension’s video edit page: a “Description” control with our primary button style, an icon, a label and no caret. It reads as “do the thing” and it opens a list.

Why this survives audits

Nothing is broken. Nothing measures wrong. It passes every contrast check on this site, because it is only wrong in what it promises.

That makes it invisible to the sweeps on Checking contrast and to a design review of a static screenshot. It took somebody using the product to find it.

Worth remembering when a page comes back clean: a clean audit means no measured failure, not that the page is right.

The rule

  • A trigger that opens a menu, panel or popover carries a caret, whatever its fill.
  • Keep the fill. On the extension these sit on YouTube’s pages beside our purple pill, so the brand fill is a deliberate product decision. The problem was never the colour.
  • Reach for the component that already exists before adding a caret by hand. The extension had FlexPrimaryBtnWithCaret on its share tab and simply did not use it on the other two. This was not a missing pattern; it was a pattern nobody reached for.

Where the CMS stands

Checked on 2026-08-05. Nine disclosure controls on the projects page, all nine carry an affordance, so the estate is clean today. But it is clean by accident rather than by rule, and that is worth knowing.

React Bootstrap’s Dropdown.Toggle adds .dropdown-toggle, which draws a caret through ::after. Every CMS dropdown uses that component, so every CMS dropdown gets a caret for free. Nothing in the theme suppresses it.

The failure mode is bypassing Dropdown.Toggle. The extension’s bug was a NavDropdown with a custom button as its trigger, which never gets the class and so never gets the caret. Any CMS dropdown built with a custom toggle, an as={...} prop or a forwarded ref would land in exactly the same place.

So the rule is not “use Bootstrap”. It is: if you replace the toggle, you own the caret.

Checking it

Run on any page. It lists every control that opens something and whether it says so.

[...document.querySelectorAll('[data-bs-toggle=dropdown], .dropdown-toggle, [aria-haspopup], [aria-expanded]')]
  .filter(e => e.getBoundingClientRect().width > 0)
  .map(e => {
    const after = getComputedStyle(e, '::after');
    const pseudo = after.content && after.content !== 'none' && after.content !== 'normal';
    const icon = !!e.querySelector('svg, i.bi-chevron-down, i.bi-caret-down-fill, img');
    return { text: (e.textContent || '').trim().slice(0, 22), caret: pseudo || icon };
  })
  .filter(r => !r.caret);

An empty array is a pass. Anything returned is a control that opens something without saying so.