# Strength Select

Strength as a track you drag: one seat per level, the playhead riding the fill's leading edge, and a slow drift of motes across the fill of the top level.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





<ComponentPreview name="strength-select">
  <StrengthSelectDemo />
</ComponentPreview>

## Installation [#installation]

```bash
npx shadcn@latest add https://motif-ui.vercel.app/r/strength-select.json
```

The item ships one keyframe, `strength-mote`, in its `css` — `shadcn add` writes it into
your stylesheet along with the component. There is no animation library in the chain: a
translate across a box is a CSS animation, and the component has no reason to carry a
runtime for it.

## Usage [#usage]

```tsx
import { StrengthSelect } from "@/components/ui/strength-select";

export function Defaults() {
  const [strength, setStrength] = useState("Medium");

  return (
    <div className="space-y-2">
      <div className="font-medium text-muted-foreground text-xs">Strength</div>
      <StrengthSelect
        aria-label="Strength"
        onChange={setStrength}
        value={strength}
      />
    </div>
  );
}
```

## The heading is yours [#the-heading-is-yours]

The control draws no label. A field's label belongs to the form around it — above, beside,
in a `<legend>` — and that is layout the caller owns, not something a select should decide
for the page it lands in. So the component renders the track and its own level names, and
no heading above them.

That leaves one line of wiring: a heading you write is plain text, so pass `aria-label`
with the same words and the name reaches a screen reader too.

## A slider that snaps to names [#a-slider-that-snaps-to-names]

The duration slider is dragged because a duration is a length: every point on its track is
a number of seconds, and the number is the value. Strength is not that. Its levels have
names, and there is nothing between `High` and `Extra high` — no value sits at 70% of the
way there.

So the gesture is a slider's and the value is a list's. The pointer runs freely along the
track, and the seat it is standing in is the level it lands on. That is where the snap
comes from: not from a continuous number being rounded to a step, but from the track being
divided into seats that each already have a name. The two controls are the same shape
because they sit in the same column of the same form; what differs is that the duration
slider's number is the answer and this one's index is only the spelling of it.

The seats are what make the drag honest, and they are the same partition the names below
the track use: a fifth of the track is `Low` because the word `Low` sits under that fifth.
Snapping to the nearest edge instead would put `Low` a fifth of the way across the track,
which is where the word `Medium` is.

## The fill is the amount [#the-fill-is-the-amount]

Each level owns a seat of the track, so `Low` fills one fifth of it and `Ultra` fills all
five. The amount is drawn as an amount, which is what makes the levels comparable at a
glance: two levels apart is twice the fill, and the empty remainder of the track is what a
higher level would buy.

The fill's leading edge is the boundary of the chosen seat, and the playhead stands on it —
the same bar the duration slider carries, in the same place. So the level is told twice
over and neither half has to be decoded: the length says how much, and the bold name under
the track says which.

The edge is the seat's *far* edge, which means the playhead ends up as much as one seat
ahead of the pointer that put it there: press the first fifth and the fill runs to the end
of the first fifth. That is the price of "the fill is the amount" — the seat is what you
press, and the length is what you bought — and the seat tint following the pointer is what
keeps the two readable while you drag.

`motion-reduce:transition-none` parks the fill for anyone who has asked for less movement.

## Only the ceiling moves [#only-the-ceiling-moves]

The top level — wherever the list ends, three levels or nine — carries a slow drift of
motes across its fill. Every level below it is still. Five levels do not need five
animations: motion in a form is noise, and a control that is always moving stops being
read as a control. Giving it to the one level that costs the most buys the ceiling a
character the other four do not have, and it costs the levels below nothing.

It is slow on purpose. A mote crosses the fill in 3.4–5 seconds, which reads as drift; the
first version crossed in under a second and read as a flicker you had to look away from.
Each mote starts partway through its own crossing — a negative `animation-delay`, which is
how CSS says an animation has already been running — so the row is spread across the fill
at every instant. Their crossing times differ as well, so the spread keeps shifting instead
of settling into a pattern.

The first version got this wrong, and the bug is worth keeping written down: it staggered
the motes by a *positive* delay from a shared start, so all twelve crossed as one clump and
the bar sat empty between passes — half a minute of watching could show no mote at all.
Phasing is the whole point of a drift; a stagger is not a phase.

The placement is worked out from each mote's index by a hash rather than from
`Math.random`, so the server and the client draw the same row and none of it has to be
generated in an effect. The hash is integer arithmetic on purpose. `Math.sin` looks like a
harmless way to spread points — it is what the first version used — but it is not specified
to the last bit, and the browser's engine disagrees with the server's a few ulps out. Those
digits went straight into the `style` attribute: the server wrote
`animation:strength-mote 3.491549070959445s` where the browser computed
`3.4915490709710864s`, and React compares that attribute as text, so it called the two
different rows a hydration mismatch. `Math.imul` is exact integer multiplication, so an
index gives the same number in every engine.

Each crossing and each height is then rounded — a hundredth of a second, a tenth of a
percent — which is finer than the drift can show and keeps a float's full expansion out of
the markup.

Each mote's carrier spans the fill, so `100%` of its travel is the fill's own width:
nothing is measured in pixels, and the drift works the same whatever the level's fill is
worth.

Under `motion-reduce` the drift is not drawn at all — not paused, not stilled: `Low`
through the top level are the one control they always were.

## Every name is a target [#every-name-is-a-target]

The names sit under the track, one per seat, and pressing one chooses its level. The
heading above the control is a label, so it does nothing when pressed; these are not
labels, so they do — the level you read is the level you press.

The whole control is the pointer's surface, so a press on a name is resolved the same way a
drag is: the x lands in a seat, and that seat's level is the one you get. The word and the
fifth of the track above it cannot disagree, because they are the same partition being
asked.

What they are not is a second set of tab stops. A drag, a press on a name, a press on the
track and an arrow key all resolve to a seat, and five separately focusable names would ask
a keyboard user to walk six stops to describe one value. The focus belongs to the track and
the arrows move the selection — so a level is never focused without being chosen.

The tint is what keeps the pointer and the names in step while you drag: the seat under the
pointer lights and its name comes forward, before anything is chosen. The light is the
foreground token at 5% rather than `muted` or `accent` — in light mode those two are the
same lightness as the fill, so a `muted` hover would disappear on the part of the track the
selection already covers. An
outline was the other candidate and lost: a ring the height of the track reads as a box
floating in it rather than as a seat being chosen. The highlight's own corners follow the
same reasoning — it carries the track's radius on the outside of the row and is square on
every edge that meets another seat, because a rounded corner there would poke past the
straight boundary the fill is drawn with.

## Any ordered list [#any-ordered-list]

`options` is any list of level names, weakest first; the count sets the segments, and the
component never parses what the names mean. Keep them ascending — the first entry is the
one the track treats as the smallest, and the **last** is the one that drifts.

<StrengthSelectEffortDemo />

## Accessibility [#accessibility]

The track is a `role="slider"` named by `aria-label`, with `aria-valuemin`, `aria-valuemax`
and `aria-valuenow` taken over the *index* of the level, and `aria-valuetext` carrying the
name. The index is what a slider is required to expose; the name is what the level actually
is, and it is what gets announced — this is the case `aria-valuetext` exists for, a value
that is chosen from a range and read as a word.

It is one tab stop. `↑`/`→` move one level stronger and `↓`/`←` one weaker, `Home` and
`End` go to the ends, and the focus stays on the track while the selection moves. The seat
names are `aria-hidden` — the level they spell is already in `aria-valuetext`, and reading
five names out around the slider would say the same thing twice. So are the fill and the
motes, which carry nothing the value does not.

`touch-none` stops the browser from claiming the horizontal drag as a scroll. Unlike the
duration slider, nothing is held back for a release: a drag commits each level as it
crosses it, because each crossing is already a whole named level rather than an
intermediate number.

## Component source [#component-source]

<ComponentSource name="strength-select" src="registry/new-york/strength-select.tsx" title="strength-select.tsx" />
