> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/floating-button.html
> Source: specs/components/floating-button/floating-button.md, specs/components/floating-button/floating-button-a11y.md, specs/components/floating-button/capabilities.json

---

# Floating Button · Oymyakon DS 3

> An action that floats above the content it acts on, and opens from an icon-only square into a
> labelled button without becoming a different control.

**Version:** 3.3.0 · **Status:** Ready · **Figma:** `[FloatingButton] 3.3` (node `16356:7288`) —
`L-FloatingButton` (node `16356:5907`), `M-FloatingButton` (node `16356:6925`)

---

## 1. Description

Floating Button carries an action that belongs to the screen rather than to a place in it: recentre
the map, start a new message, add an item. It sits above the content on its own elevation instead of
in the flow, which is what makes it reachable while the content underneath keeps scrolling.

Its distinguishing axis is `Component`. The same instance is either a **Squircle** — an icon alone
in a square — or a **Button**, the square opened out to carry a label beside that icon. Both are one
component, not two: the height, the corner and the elevation hold across the change, and only the
width and the label arrive or leave. That is what lets a product open the control to explain itself
and close it again once the person has seen it, without swapping one component for another.

It is not a [Button](https://super-dollop-pzmo65r.pages.github.io/button.md) with a modifier. Button hugs its label inside the layout;
Floating Button owns a position above it, carries its own elevation and border, and offers three
styles rather than six. A control that sits in the flow of a screen is Button, whatever its shape.

Two sizes cover it — `L` for a primary screen action, `M` where the control shares space with other
floating chrome.

---

## 2. Anatomy

The parts follow the DS-wide **StartSlot / StartText / EndSlot** naming, the same as
[Button](https://super-dollop-pzmo65r.pages.github.io/button.md) — by construction, not by coincidence: the component began as a
**customButton** configuration, set up for the floating role and then promoted into a component of
its own. The promotion is what carried Button's slot semantics and padding contract over intact,
and what froze them here together with the axes Button does not have — `Component`, the elevation,
the border and the three floating styles. It is a separate component now, not a Button instance.

```
FloatingButton         .floating-button          hug x s56 (L) / s48 (M) — required
├── StartSlot          .floating-button__start-slot   swap slot, default IconContainer — optional
├── StartText          .floating-button__start-text   label column — only in Component=Button
│   ├── Title row      .floating-button__title-row    Heading 4 — required inside StartText
│   └── Subtitle row   .floating-button__subtitle-row second row, s2 gap — optional
├── EndSlot            .floating-button__end-slot     swap slot, default IconContainer — optional
└── Indicator          .floating-button__indicator    Indicator overlay — optional, off by default
```

| Element | Class | Required | Notes |
|---|---|---|---|
| Root | `.floating-button` | required | The focusable, clickable element. Carries the radius, the elevation and the border |
| StartSlot | `.floating-button__start-slot` | optional | Figma `🔴StartSlot` — a swap slot holding a `s24` [IconContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md) by default. Off by default in `Component=Button`; it is the whole content in `Component=Squircle` |
| StartText | `.floating-button__start-text` | optional | Figma `🟡StartText` — the label column, `s2` row gap, `s8` horizontal padding. Absent in `Component=Squircle` |
| Title row | `.floating-button__title-row` | required inside StartText | Heading 4 |
| Subtitle row | `.floating-button__subtitle-row` | optional | The second row of the column |
| EndSlot | `.floating-button__end-slot` | optional | Figma `🔵EndSlot` — mirrors StartSlot |
| Indicator | `.floating-button__indicator` | optional | Figma `IndicatorContainer` — an absolutely positioned overlay the size of the root, pinning a `s12` [Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md) to the top-right corner. Off by default |

In `Component=Squircle` the root holds StartSlot alone and the square comes from the size, not from
padding: the frame is `s56` (or `s48`) with `s0` padding, and the `s24` icon centres inside it.

In `Component=Button` the root takes the preset geometry — `s12` padding, `s4` gap — and StartText
appears between the two slots. Both side slots stay off by default, so the opened form is an icon
and a label only when StartSlot is switched on.

The `Border` rectangle in Figma is a construction detail: it gives the Squircle variant its width
and carries the stroke. On web the root owns both, and there is no separate element.

---

## 3. Variants and sizes

| Parameter | Values |
|---|---|
| Size | `L` · `M` (separate component sets, chosen through the `Size` swap on `[FloatingButton] 3.3`) |
| Component | `Squircle` · `Button` (default `Squircle`) |
| Style | `Primary` · `Error` · `Inverse` (default `Primary`) |
| State | `Standard` · `Disabled` · `Skeleton` (default `Standard`; Figma names the first one `Default`) |
| RTL | `false` · `true` (default `false`) |
| StartSlot | boolean — off by default |
| EndSlot | boolean — off by default |
| Indicator | boolean — off by default |

Geometry — width hugs the content, height is fixed per size:

| Size | Component | Width | Height | Horizontal padding | Gap | Radius | Class |
|---|---|---|---|---|---|---|---|
| `L` | `Squircle` | `s56` | `s56` | `s0` | 0 | `s20` | `.floating-button--l` |
| `L` | `Button` | hug | `s56` | `s12` | `s4` | `s20` | `.floating-button--l.floating-button--open` |
| `M` | `Squircle` | `s48` | `s48` | `s0` | 0 | `s20` | `.floating-button--m` |
| `M` | `Button` | hug | `s48` | `s12` | `s4` | `s20` | `.floating-button--m.floating-button--open` |

The radius is `s20` at both sizes and in both forms. `L` collapsed therefore matches
[Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) `L` exactly — `s56` at `s20` — while `M` collapsed does **not**
match Squircle `M`, which is `s48` at `s16`. The `Squircle` variant is named for the shape it reads
as, not for the component: nothing here is an instance of Squircle.

**What the two forms share.** Height, radius, elevation, border, and the padding contract. Opening
the control changes its width and brings the label; it changes nothing else, which is what keeps it
one component across the axis.

**Padding, and why the icon stays centred.** The opened form uses the same optical balance as the
Button presets: `s12` on the root, `s4` on the outer edge of a side slot, `s8` on StartText — so a
side ending in an icon reads at 16 and a side ending in a label at 20 (see
[`button.md`](https://super-dollop-pzmo65r.pages.github.io/button.md) §8). Collapsed, both of those inner paddings are absent along
with the label, and the `s24` icon centres in the square on its own.

---

## 4. States

| State | Description |
|---|---|
| Standard | Rest. Fill and content follow the `Style` axis (§6) |
| Pressed | Held. Scales to 95% via `pushButton` (§5). A motion step, not a variant — `[FloatingButton] 3.3` has no Pressed member on the `State` axis |
| Disabled | Non-interactive. Every `Style` collapses onto one appearance: `Surface/Floating` fill with `TextAndIcon/Disabled` content, so `Inverse` loses its dark fill as well |
| Skeleton | Placeholder while content resolves: the control is replaced by a [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) of its own shape. Collapsed that is the square; opened it is a fixed width rather than a hug, since there is no label left to hug — 120 at `L`. **The opened width at `M` is not captured from Figma yet**, so the web build leaves `M` hugging rather than approximating it |

`Component` is an axis of its own rather than a state — a Floating Button is Squircle or Button in
every one of the states above.

Disabled is expressed by a token swap, not by `opacity`, which keeps the content's contrast
predictable on every surface.

---

## 5. Animation and behavior

Motion follows [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). The press is the same
`pushButton` Button uses; opening and closing is a state change of one element, one step slower
than an ordinary state flip — the stretch travels real distance, so it takes `State/Slow` (300ms)
rather than `State/Default` (200ms), the same curve.

| Event | Pattern | Token | CSS |
|---|---|---|---|
| Press | `pushButton` | `--component-push-button-press` | `transform` (100% → 95%) |
| Release | `pushButton` | `--component-push-button-release` | `transform` (95% → 100%) |
| Squircle ↔ Button | `Transforming/State/Slow` | `--transforming-state-slow` | the control's `width`; the label's `width` and `opacity` |

The press moves the scale alone: no `Style` defines a pressed fill, so nothing else is in the
transition. **Standard ↔ Disabled is a swap, not an animation** — the state changes instantly.

Reduced motion zeroes each of these at the token in
[`motion.css`](https://github.com/inDriver/oymyakon-ds/blob/main/tokens/generated/motion.css), so the component carries no override of its
own: the press loses its dip and the control opens without travelling.

**Opening is a layout change, and that is deliberate.** `motion.md` asks for compositor-friendly
properties only. A control that grows cannot be done on the compositor without distorting its own
label, so the width follows a layout animation here. It is one small element, the timing is the
catalogued `Transforming/State/Slow` rather than an invented duration, and reduced motion removes
it entirely.

**What interpolates, exactly** — every piece between its two forms' §8 values, all on the one
token: the control's `width` (`s56`/`s48` ↔ hug), the root's horizontal padding (`s0` ↔ `s12`),
the root gap (`s0` ↔ `s4`), the side slot's outer padding (`s0` ↔ `s4`), and the label column's
`width` (0 ↔ hug), horizontal padding (`s0` ↔ `s8`) and `opacity` (0 ↔ 1). The icon never moves:
16 from the leading edge in both forms is the same number by two different routes.

`[FloatingButton] 3.3` has no motion axis — Figma expresses the two forms as `Component` variants
and the transition between them is the consumer's. The web build owns the interpolation: the
control's `width` travels on the state token through `interpolate-size: allow-keywords`, and the
label collapses to zero width and `opacity` on the same token — every animated piece rides one
token, so the hug width equals the sum of its parts at every frame. One markup serves both forms:
a closed control may keep StartText mounted, collapsed. The label's rows anchor to the reading
edge and the column clips: the growing column **reveals** the standing text rather than sliding it
into place — centred rows would travel half the growth and read as a glitch. The price, and it is
deliberate: two rows of different widths align to the reading edge rather than centring on each
other. A browser without `interpolate-size` snaps between the two forms, which is also exactly
what reduced motion does.

---

## 6. Color tokens

One token name per role; Light/Dark substitution happens by name.

| Style | Class | Fill — Standard | Content — Standard |
|---|---|---|---|
| Primary | `.floating-button--primary` | `var(--surface-floating)` | `var(--text-and-icon-primary)` |
| Error | `.floating-button--error` | `var(--surface-floating)` | `var(--text-and-icon-error)` |
| Inverse | `.floating-button--inverse` | `var(--background-inverse-primary)` | `var(--text-and-icon-inverse-primary)` |

`Surface/Floating` is the palette's floating-surface token (Colors 3.10.0): white in Light, and in
Dark a step **lighter** than `Background/Primary` — elevation expressed through lightness, so a
floating control separates from the background even where a shadow cannot.

| Element | Token — Disabled |
|---|---|
| Fill, every `Style` | `var(--surface-floating)` |
| Content, every `Style` | `var(--text-and-icon-disabled)` |

| Element | Token |
|---|---|
| Border, every `Style` and state | `var(--border-transparent)`, 1 |
| Elevation, every `Style` and state | `var(--shadow-s)` |

`Error` reads as a tinted icon or label on the ordinary surface rather than a red block, the same
choice Button makes, so a destructive floating action stays legible without shouting. `Inverse` is
the one style that changes the surface, and it is the one style Disabled takes away — a disabled
Inverse is a light control with disabled content, not a dark one.

The border is `Border/Transparent` rather than nothing: it holds the control's edge against a
surface of any colour, which a floating element cannot assume.

Every token in this section is the **default** on the custom base, not a lock: a design request
may replace any of them on a custom instance, provided the replacement is a semantic token. The
`L` and `M` presets lock them — a preset instance changes its slots and its label, never these
tokens.

---

## 7. Typography

| Element | Style |
|---|---|
| Title row | Heading 4 — Suisse Intl Semibold 17 / 20 |
| Subtitle row | Compact Body — Suisse Intl Book 14 / 16 |

The title is Heading 4 at both sizes: unlike Button, the label does not step down with the size,
because a Floating Button carries one short action and `M` differs from `L` in the room around it
rather than in the weight of what it says.

Scaling is `full`. A row holds a single line and truncates to an ellipsis; a label that would wrap
belongs outside the control.

Both styles are **defaults** on the custom base, replaceable per instance by another DS text
style; the `L` and `M` presets lock them.

---

## 8. Spacing

Every number in this section is the **default**. A design request may change any of them on an
instance, provided the replacement is a token from the SP scale rather than a raw value.

| Property | Token | Value |
|---|---|---|
| Height — `L` | `var(--sp-s56)` | 56 |
| Height — `M` | `var(--sp-s48)` | 48 |
| Radius, both sizes and both forms | `var(--sp-s20)` | 20 |
| Horizontal padding — `Component=Squircle` | `var(--sp-s0)` | 0 — the square comes from the size |
| Horizontal padding — `Component=Button` | `var(--sp-s12)` | 12 |
| Gap — StartSlot / StartText / EndSlot | `var(--sp-s4)` | 4, in `Component=Button` only |
| StartSlot / EndSlot outer padding, toward the root edge | `var(--sp-s4)` | 4, in `Component=Button` only |
| StartText horizontal padding | `var(--sp-s8)` | 8 |
| StartText row gap | `var(--sp-s2)` | 2 |
| IconContainer in StartSlot / EndSlot | `var(--sp-s24)` | 24 x 24, at both sizes |
| Indicator overlay | `var(--sp-s12)` | 12, pinned to the top-right corner of the root |
| Border width | — | 1. The hairline has no SP token: the scale runs `var(--sp-s0)` → `var(--sp-s2)`, so 1 is the DS-wide literal here, as on [Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) and [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) |

Vertical padding is `s0` at both sizes: the height is fixed and the content centres inside it.

`Component=Squircle` collapses to a true square only because the icon is `s24` and the padding is
`s0` — the width is the height. In `Component=Button` the width hugs whatever the label is.

---

## 9. Usage context

**When to use**

- An action that belongs to the whole screen and has to stay reachable while the content scrolls.
- An action a person needs from any position in a long list or on a map.
- A destructive floating action, in `Error`.
- Over imagery or a dark surface, in `Inverse`.

**When not to use**

- An action that belongs to a position in the content — that is [Button](https://super-dollop-pzmo65r.pages.github.io/button.md), which
  sits in the flow and hugs its label there.
- The single screen-level action pinned to the bottom of a flow — that is Button's `Main CTA`, which
  is full-bleed and part of the layout rather than floating over it.
- A control whose whole content is an icon and which never opens — that is Icon Button.
- More than one floating action on a screen: two of them compete for the same corner and neither
  reads as the screen's action.

**Opening and closing.** The `Component` axis is for a control that has something to say once — on
arrival, or the first time a person reaches the screen — and then gets out of the way. A Floating
Button that stays open permanently is a Button in the wrong place.

**Related components** — [IconContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md) (StartSlot, EndSlot),
[Slot](https://super-dollop-pzmo65r.pages.github.io/slot.md), [Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md) (the overlay),
[Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) (the `Skeleton` state), [Button](https://super-dollop-pzmo65r.pages.github.io/button.md),
[Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) (the shape `L` collapsed matches, not a dependency).

---

## 10. Accessibility

Full spec: [`floating-button-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/floating-button.md).

The root is the focusable element and the only one — no slot, row or overlay takes focus, which
keeps one stop per control. In `Component=Squircle` there is no visible label, so the accessible name
comes from `aria-label`; opening the control does not change that name, so a person who focused it
closed and hears it open is still on the same action.

`Disabled` is announced rather than signalled by colour alone, and `Skeleton` is removed from the
accessibility tree entirely.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `floating-button` |
| `data-ds-preset` | `custom` · `l` · `m` — `custom` is the base configuration (§2, §6) |
| `data-ds-variant` | the `Style` value in lower case — `primary` · `error` · `inverse`. The `custom` base may omit it: its colour tokens are instance-replaced, so no preset `Style` name applies |
| `data-ds-state` | `standard` · `disabled` · `skeleton` |
| Coverage unit | yes |
| Tap target model | root — the whole control is the only target, in both forms |
| Actions | `tap` |
| Internal targets | none |
| Emits value | no — `data-ds-state` reports status, not a user value |

The `Component` axis is not an analytics dimension: opening the control is presentation, and both
forms report the same action. A product that needs to know whether a person acted while it was open
records that on its own event rather than on the component.

```html
<button type="button"
        class="floating-button floating-button--l floating-button--primary floating-button--open"
        data-ds-component="floating-button"
        data-ds-component-id="map.recentre"
        data-ds-preset="l"
        data-ds-variant="primary"
        data-ds-state="standard"
        data-ds-action="tap">
  <span class="floating-button__start-slot" aria-hidden="true"><!-- IconContainer --></span>
  <span class="floating-button__start-text">
    <span class="floating-button__title-row">Recentre</span>
  </span>
</button>
```

Collapsed, the same control drops `--open` and StartText, and takes an `aria-label` in place of the
visible label. A component swapped into a slot keeps its own `data-ds-component` and counts for
coverage, but registers no tap of its own.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.3.0 | 2026-08-06 | **Version realigned to the Figma line and the component marked Ready** (owner). The Figma component is `[FloatingButton] 3.3`, not `3.0` — the 3.0.0 read on 2026-08-04 recorded the wrong line, and every version from 3.0.0 to 3.0.9 inherited it. Per `spec-conventions` the spec version tracks the Figma component's, so `X` moves 0 → 3 and this becomes the component major; the prose references and the node identity above are confirmed for `3.3` by the owner. Nothing about the contract changes — the axes, geometry, tokens and behaviour in §§2–11 are what the web build shipped and what the page shows. Status `Draft` → **`Ready`**. One gap stays open and is deliberately not blocking: the opened-`Skeleton` width at `M` is still unread from Figma, so the build leaves `M` hugging. Stable componentKeys are still worth capturing on the next Figma pass — a node-id does not survive a library republish — but that is durability housekeeping, not a doubt about the current identity. |
| 3.0.9 | 2026-08-06 | The machine contract ships: `capabilities.json` authored (axes, anatomy, constraints, analytics, RTL), registry row added, `capabilities:validate` green. The adversarial review of the contract fed one fix back into this spec: §11's `data-ds-preset` list gains **`custom`** — the base configuration the spec itself legalised in 3.0.2 — and notes the base may omit `data-ds-variant`, since its colour tokens are instance-replaced. Known gap carried in the contract: Figma componentKeys are not captured (node-ids only), to be read on the next Figma pass. |
| 3.0.8 | 2026-08-06 | Handoff audit of §5, before the first commit of the web build. Fixes a contradiction 3.0.7 missed: the layout-change paragraph still named `Transforming/State/Default` while the table said `State/Slow` — a developer would have met two timings. The reduced-motion line stops claiming an "instant fill change" (fill is not in the transition since 3.0.5). New **"What interpolates, exactly"** paragraph enumerates every animated piece between its two §8 values — control width, root padding and gap, slot outer padding, label width/padding/opacity — and records the icon-never-moves invariant: 16 from the leading edge in both forms, by two different routes. |
| 3.0.7 | 2026-08-05 | The opening slows one catalogued step: `Transforming/State/Default` → **`Transforming/State/Slow`** (200 → 300ms, same `standard-ease-in-out`) — the stretch travels real distance, so it takes more room than a state flip (owner call). The press stays on `pushButton` at 200ms. Every opening piece — root width/padding/gap, slot padding, label width/padding/opacity — moves to the same token, so the sum-of-parts invariant holds. |
| 3.0.6 | 2026-08-05 | The opening's label motion fixed (owner catch: the label visibly slid out from under the icon with a "bounce"). Cause: the rows were **centred** in the width-animating column, so a row's leading edge travelled half the growth; and the column only clipped when closed. Now the rows **anchor to the reading edge** (`flex-start` — right in RTL) and the column clips at all times: the growing column reveals standing text instead of sliding it. Recorded trade-off: two rows of different widths align to the reading edge rather than centring on each other — if Figma's TextContainer centres them, this diverges and wants an owner call. §5 carries the reveal-anchor line. |
| 3.0.5 | 2026-08-05 | §5 reworked on two owner decisions. **Standard ↔ Disabled is a swap, not an animation** — the row leaves the motion table and `color`/`background-color` leave the web transition; the press moves the scale alone, since no `Style` defines a pressed fill (the `background-color` the 3.0.0 table carried was Button's pattern, not this component's behaviour). **The Squircle ↔ Button opening is built**: the control's `width` travels on `--transforming-state-default` via `interpolate-size: allow-keywords`, the label collapses to zero width and `opacity` on the same token, and one markup serves both forms — a closed control keeps StartText mounted, collapsed, which is also what keeps the icon centred in the square. Every animated piece rides one token, so the hug width equals the sum of its parts at every frame; browsers without `interpolate-size` — and reduced motion — snap between the forms. The earlier grid-`0fr` attempt (reverted, 2026-08-04) is superseded by this approach. |
| 3.0.4 | 2026-08-05 | Construction recorded (owner): the component **began as a customButton configuration** — set up for the floating role, then promoted into a component of its own. That is why the slot semantics and the padding contract match Button's exactly (§2 carried the naming parity but not the reason), and it does not make the control a Button instance: the promotion froze the configuration into a separate component with axes Button does not have. Page: the Anatomy intro carries the same line. |
| 3.0.3 | 2026-08-05 | The fill moves from `Background/Primary` to **`Surface/Floating`** — the floating-surface token Colors 3.10.0 added (owner decision, checked against the full palette): white in Light and a step *lighter* than the background in Dark (`Grey/870` vs `Grey/875`), elevation through lightness. In Light the two are identical, which is how the 3.0.0 read mistook it for `Background/Primary`. Applies to `Primary` and `Error` Standard (§6) and to the Disabled collapse (§4, §6); `Inverse` keeps `Background/InversePrimary`. Repo tokens synced 3.8.0 → 3.10.0 in the same commit (`--surface-floating`, `--surface-dark-overlay`, `--p-grey-870` new; dark `--border-transparent` re-pointed to `Grey 850 (90%)` — this component's edge in Dark changes with it). |
| 3.0.2 | 2026-08-05 | The custom/preset contract recorded (owner decision): on the **custom base** every colour token (§6) and text style (§7) is a default, replaceable per instance by another DS token — the same rule §8 already stated for spacing; the `L` and `M` **presets lock them** — a preset instance changes its slots and its label, never the tokens. Page: States gains a `Custom · Standard` block with the defaults pointed and the replaceability noted in secondary text, and Elevation gets its own pointer — the dot sits in the shadow itself, under the open form's bottom edge. |
| 3.0.1 | 2026-08-05 | Corrects the border width, which §6 and §8 both gave as `s1` / `var(--sp-s1)` — **a token that does not exist**. The SP scale runs `var(--sp-s0)` → `var(--sp-s2)`, so the hairline is the DS-wide literal `1`, the same way [Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) and [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) already draw it. Caught while building the web implementation: the value was the only one of the eighteen tokens the two specs name that resolved to nothing. Web: `.floating-button` lands in `shared.css` — both forms, three styles, Disabled, Skeleton, the Indicator overlay, the focus ring and the opt-in RTL mirror. The Squircle ↔ Button interpolation (§5) is deliberately not in it yet; the two forms are static until opening is built as its own step. §4 also promotes the opened-`Skeleton` gap out of the changelog and into the state table: `L` is 120 and `M` is still unread, so the build leaves `M` hugging rather than approximating a number that is not in the DS. |
| 3.0.0 | 2026-08-04 | Initial spec, read from Figma `[FloatingButton] 3.0` — the published wrapper (node `16356:7288`) whose `Size` swap picks `L-FloatingButton` (`16356:5907`) or `M-FloatingButton` (`16356:6925`). The component's own axis is **`Component`: Squircle or Button** — one control that opens from an icon-only square into a labelled button, holding its height, radius, elevation and border across the change. Three styles (`Primary`, `Error`, `Inverse`), `State x RTL` alongside, and `Indicator` / `StartSlot` / `EndSlot` booleans, all off by default. Colours read from the bound Figma variables rather than from hex. Two facts worth recording: the radius is `s20` at both sizes, so `L` collapsed is Squircle `L` exactly while `M` collapsed is **not** Squircle `M` (`s48` at `s16`) — the variant is named for the shape, not the component; and `Inverse` is the one style Disabled takes away, since a disabled Inverse is a light control. Web implementation (`shared.css`, preview page, `capabilities.json`) pending. Not captured yet: the fixed width of the opened `Skeleton` at `M` — `L` is 120. |

---

# Floating Button — Accessibility

**Component:** Floating Button
**Version:** 3.3.0
**Spec:** [`floating-button.md`](https://super-dollop-pzmo65r.pages.github.io/floating-button.md)

---

## Role and ARIA

| Attribute | Value | Where |
|---|---|---|
| `type` | `"button"` | The root — a bare `<button>` in a form defaults to `submit` |
| `role` | `"button"` | Native on `<button>`; a custom element needs it explicitly |
| `aria-label` | The action's text | The root, in `Component=Squircle`, where no label is on screen |
| `aria-expanded` | — | **Not used.** The control does not disclose anything; opening reveals its own label, not a region |
| `aria-disabled` | `"true"` | The root, in the `Disabled` state |
| `aria-hidden` | `"true"` | StartSlot / EndSlot holding a decorative icon, and the Indicator overlay when it duplicates something already said |

A native `<button>` carries the role, the focus behaviour and both activation keys on its own, which
is why it is the DS markup. A `<div>` or `<span>` standing in for it needs `role="button"`,
`tabindex="0"` and its own key handling.

**The name does not change when the control opens.** Collapsed, the accessible name is the
`aria-label`; opened, the visible label says the same thing. Keeping them identical means a person
who focused the square and hears it again opened is still on one action rather than what sounds
like a new one. Where the two must differ, the `aria-label` is the one that stays.

`aria-expanded` is deliberately absent. It would announce the control as a disclosure and invite a
person to look for the region it opened — there is none. The `Component` axis is presentation.

The Indicator overlay carries meaning only when it is not already in the label. A dot that says
"there is something new here" is exposed, through the label or `aria-describedby`; a dot that
repeats a count already spoken is `aria-hidden`.

---

## Focus

The root is the focusable element, and the only one — no slot, row or overlay takes focus, which
keeps one stop per control.

| Requirement | Implementation |
|---|---|
| Reachable by Tab | `<button>` is focusable natively |
| Visible focus ring | `:focus-visible` — `s2` inset outline in `var(--text-and-icon-primary)`, radius `s20` to follow the corner. **`Inverse` takes `var(--text-and-icon-inverse-primary)` instead**: the default ring is the same primitive as the inverse fill in Dark (both white) and a neighbouring grey in Light — invisible either way. The style's content colour is the one token guaranteed to contrast with its fill |
| Disabled | `aria-disabled` keeps it in the tab order; `disabled` removes it |
| Opening or closing | Focus is retained on the same element throughout |

The ring is inset so it stays inside the control rather than sitting on the elevation, where a
shadow would compete with it.

Because a Floating Button sits above the content rather than in it, its place in the tab order is
the consumer's decision. It follows the DOM, so the markup goes where the action belongs in reading
order — commonly right after the region it acts on, not at the end of the document where the
elevation might suggest.

---

## Keyboard

| Key | Behaviour |
|---|---|
| `Tab` / `Shift+Tab` | Moves focus to and from the control |
| `Enter` | Activates |
| `Space` | Activates |

Both `Enter` and `Space` activate a button, which is the platform behaviour a native `<button>`
provides and a custom element reproduces.

Opening and closing is not a keyboard action of its own: the product decides when the control opens,
and the person acts on it in either form with the same two keys. In `Disabled` neither key has any
effect — **and that is the handler's job, not the markup's**: `aria-disabled` keeps a native
`<button>` fully clickable, so the activation handler guards it
(`if (el.getAttribute('aria-disabled') === 'true') return`) — the same guard Checkbox and
RadioButton carry. Only the hard `disabled` attribute silences the keys natively, at the price of
dropping the control out of the tab order.

---

## Contrast

Every pairing in §6 of the spec resolves through semantic tokens, so Light and Dark are covered by
one name. The pairings worth verifying when a `Style` or a host surface changes:

| Pairing | Note |
|---|---|
| `Surface/Floating` + `TextAndIcon/Primary` | The `Primary` combination |
| `Surface/Floating` + `TextAndIcon/Error` | `Error` carries the destructive meaning in the icon or label, so its contrast does the whole job |
| `Background/InversePrimary` + `TextAndIcon/InversePrimary` | `Inverse`, the one style that changes the surface |
| `Surface/Floating` + `TextAndIcon/Disabled` | The lowest-contrast pairing by intent; it stays above the 3:1 floor for disabled controls |

A floating control cannot assume what is behind it. `Border/Transparent` and `shadow-s` are what
separate it from the surface, and both need checking over imagery and over a busy map — the border
is the fallback where the shadow disappears into a dark background.

No state is signalled by colour alone. `Disabled` is announced, and `Skeleton` carries no label to
misread.

---

## Android · TalkBack

`[Label]` — the action's text, from `aria-label` when collapsed and from the visible label when open.

### Tap target · the control

| Attribute | Description |
|---|---|
| **Voiced preview** | "[Label], Button, Double-tap to activate." |
| **Label** | [Label] |
| **Value** | — |
| **Trait** | Button |
| **Hint** | Double-tap to activate. |

### Edge states

- **Disabled:** *"[Label], Button, Disabled."* The Hint is dropped because the element is not actionable, and the control is skipped in swipe navigation.
- **Skeleton:** not exposed. The placeholder is removed from the accessibility tree so a person is not offered a control that does not exist yet.
- **Opened or collapsed:** identical. The announcement does not change with `Component`, which is what keeps the two forms one control.
- **With the Indicator on:** the dot is appended to the label only when it carries something the label does not — *"[Label], new"*. A decorative dot is not announced.

---

## iOS · VoiceOver

### Tap target · the control

| Attribute | Description |
|---|---|
| **Voiced preview** | "[Label], Button. Double-tap to activate." |
| **Label** | [Label] |
| **Value** | — |
| **Trait** | Button |
| **Hint** | Double-tap to activate. |

### Edge states

- **Dimmed:** `accessibilityTraits.notEnabled` — VoiceOver appends "Dimmed"; the control stays in the rotor so its state is discoverable.
- **Skeleton:** `accessibilityElementsHidden` — the placeholder is not reachable.
- **Opened or collapsed:** identical, as on Android.
- **Over content:** the control is a sibling of the content it floats above, not a child, so it is reached in its own right rather than inside whatever is scrolling underneath.

The two platforms part company on the disabled control, as they do everywhere: TalkBack drops it out
of swipe navigation, VoiceOver keeps it in the rotor.

---

## Target size

| Size | Height | Against WCAG 2.5.5 |
|---|---|---|
| `L` | `s56` | Above the 48 the DS uses |
| `M` | `s48` | Meets it |

Collapsed, the width equals the height, so both sizes clear the minimum on both axes. The SP scale
modes lift each height, bringing `M` to 62 at 130% and 72 at 150%.

A floating control needs clear space as well as size: it overlaps content, so the area under it
cannot hold another target. That spacing belongs to the screen, not to the component.

---

## RTL

Floating Button opts into RTL through its own `RTL` axis (`false` by default), so the mirroring is
part of the component contract rather than a page-level concern.

| Element | RTL=true |
|---|---|
| Slot order | StartSlot and EndSlot swap sides — start follows the reading direction |
| Label alignment | Follows the text direction |
| Directional icons | Chevron, arrow and back glyphs flip; other icons keep their orientation (see [`icon-container.md`](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md) § 3.5) |
| Indicator overlay | Moves to the top-left corner, which is the trailing corner in RTL |
| Opening | The control opens toward the reading direction — leftwards in RTL |
| Padding | The `s12` horizontal padding is symmetric, so it needs no mirroring |

The press animation is unaffected: `pushButton` scales from the centre.

---

## Testing checklist

- [ ] Tab reaches the control; the focus ring is visible over imagery and over a dark surface
- [ ] Both `Enter` and `Space` activate
- [ ] Collapsed, the control has an `aria-label`, and it says the same thing the open label says
- [ ] Opening and closing does not change the accessible name and does not move focus
- [ ] `aria-expanded` is absent
- [ ] `Disabled` does not respond to `Enter` or `Space`
- [ ] `Skeleton` is absent from the accessibility tree
- [ ] A decorative icon in StartSlot / EndSlot is `aria-hidden` and adds nothing to the announcement
- [ ] The Indicator is exposed only when it says something the label does not
- [ ] The control's place in the tab order follows the region it acts on, not its elevation
- [ ] TalkBack announces label + "Button", with "Disabled" appended in that state
- [ ] VoiceOver announces label + Button trait, with "Dimmed" appended when disabled
- [ ] The border stays visible where the shadow does not — over a dark background
- [ ] RTL=true swaps the slots, moves the Indicator to the top-left and opens leftwards
- [ ] `data-ds-state` matches the ARIA state after every transition

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.3.0 | 2026-08-06 | Version realigned to the Figma line — the component is `[FloatingButton] 3.3`, not `3.0` (see the main spec's 3.3.0 row); the a11y content is unchanged from 3.0.1. |
| 3.0.1 | 2026-08-06 | Three a11y-reviewer catches. **The `Inverse` focus ring gets its own colour** — `var(--text-and-icon-inverse-primary)`: the default `TextAndIcon/Primary` ring is the same primitive as the inverse fill in Dark (both white) and a neighbouring grey in Light, invisible either way (§ Focus). **The Contrast table catches up with 3.0.3's fill** — three pairings still cited `Background/Primary`; the fill is `Surface/Floating` (§ Contrast). **The `aria-disabled` guard recorded** — the attribute keeps a native button clickable, so silencing Enter/Space in Disabled is the handler's job, the same guard Checkbox and RadioButton carry (§ Keyboard). |
| 3.0.0 | 2026-08-04 | Initial accessibility spec, written alongside the component spec. |

---

## Machine contract — `specs/components/floating-button/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "floating-button",
  "name": "Floating Button",
  "version": "3.3.0",
  "description": "An action floating above the content it acts on, opening from an icon-only square (Component=Squircle) into a labelled button (Component=Button) without becoming a different control — height, radius s20, elevation and border hold across the change.",
  "files": {
    "spec": "specs/components/floating-button/floating-button.md",
    "a11y": "specs/components/floating-button/floating-button-a11y.md",
    "preview": "src/floating-button.njk",
    "css": "src/shared/shared.css"
  },
  "root": {
    "class": "floating-button",
    "dataDsComponent": "floating-button"
  },
  "anatomy": {
    "startSlot": {
      "class": "floating-button__start-slot",
      "optional": true,
      "notes": "Figma 🔴StartSlot — swap slot holding a s24 IconContainer by default. Off by default in Component=Button; the whole content in Component=Squircle."
    },
    "startText": {
      "class": "floating-button__start-text",
      "optional": true,
      "notes": "Figma 🟡StartText — the label column: s2 row gap, s8 horizontal padding, rows anchored to the reading edge and clipped (the opening reveals standing text). VISIBLE in Component=Button only, but not gated out of the markup: a closed control may keep it mounted — :not(--open) collapses it to zero width and opacity, which is what makes the §5 opening interpolate instead of popping in."
    },
    "titleRow": {
      "class": "floating-button__title-row",
      "notes": "Heading 4 at BOTH sizes — the label does not step down with the size. Required inside StartText (present whenever the column is); one line, truncating to an ellipsis."
    },
    "subtitleRow": {
      "class": "floating-button__subtitle-row",
      "optional": true,
      "notes": "Compact Body — the optional second row of the column. Only meaningful inside StartText."
    },
    "endSlot": {
      "class": "floating-button__end-slot",
      "optional": true,
      "notes": "Figma 🔵EndSlot — mirrors StartSlot."
    },
    "indicator": {
      "class": "floating-button__indicator",
      "optional": true,
      "notes": "Figma IndicatorContainer — an absolutely positioned overlay the size of the root, pinning a s12 Indicator to the top-right corner (top-left in RTL). pointer-events: none; off by default."
    }
  },
  "axes": {
    "size": {
      "title": "Size",
      "type": "enum",
      "values": [
        "l",
        "m"
      ],
      "css": {
        "modifierTemplate": ".floating-button--{value}"
      },
      "figma": {
        "kind": "component-set",
        "values": {
          "l": "L-FloatingButton",
          "m": "M-FloatingButton"
        },
        "notes": "Two component sets behind the [FloatingButton] 3.3 wrapper's Size swap (nodes 16356:5907 / 16356:6925 / wrapper 16356:7288)."
      },
      "notes": "L s56 for a primary screen action, M s48 where the control shares space with other floating chrome. Radius is s20 at both."
    },
    "component": {
      "title": "Component",
      "type": "enum",
      "values": [
        "squircle",
        "button"
      ],
      "default": "squircle",
      "css": {
        "mechanism": "squircle = bare root (width from the size, padding s0); button = .floating-button--open (width hug, padding-inline s12, gap s4)"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Component",
        "values": {
          "squircle": "Squircle",
          "button": "Button"
        }
      },
      "constraints": [
        "One control, not two: height, radius, elevation, border and the padding contract hold across the change — only the width and the label arrive or leave.",
        "The web build owns the Squircle ↔ Button interpolation (spec §5); Figma expresses the two forms as static variants."
      ]
    },
    "style": {
      "title": "Style",
      "type": "enum",
      "values": [
        "primary",
        "error",
        "inverse"
      ],
      "default": "primary",
      "css": {
        "modifierTemplate": ".floating-button--{value}"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Style",
        "values": {
          "primary": "Primary",
          "error": "Error",
          "inverse": "Inverse"
        }
      },
      "customizable": "On the custom base every colour token here is a default, replaceable per instance by another semantic token; the L and M presets lock them (spec §6).",
      "notes": "Primary/Error fill with --surface-floating (the floating-surface token — a step lighter than the background in Dark); Error carries the destructive meaning in the content (--text-and-icon-error); Inverse is the one style that changes the surface (--background-inverse-primary)."
    },
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard",
        "disabled",
        "skeleton"
      ],
      "default": "standard",
      "css": {
        "mechanism": "standard = bare root; disabled = .floating-button--disabled (or :disabled); skeleton = .floating-button--skeleton with a .skeleton fill child"
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "standard": "Default",
          "disabled": "Disabled",
          "skeleton": "Skeleton"
        },
        "notes": "Figma names the first member Default; the DS calls it Standard. Pressed is NOT a member — it is a motion step (pushButton)."
      },
      "constraints": [
        "Disabled collapses every Style onto one appearance — --surface-floating fill with --text-and-icon-disabled content — so Inverse loses its dark fill as well. A token swap, never opacity.",
        "Standard ↔ Disabled is a swap, not an animation: color and background-color are deliberately not in the transition.",
        "Skeleton is removed from the accessibility tree entirely."
      ]
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "[dir=\"rtl\"] on or above the root, or .floating-button--rtl (class path applies row-reverse; the dir path must NOT double it)"
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL",
        "values": {
          "false": "false",
          "true": "true"
        }
      },
      "notes": "Slots swap sides, directional icons flip, the Indicator moves to the top-left corner, the control opens toward the reading direction. The s12 root padding is symmetric; the press scales from the centre."
    },
    "startSlot": {
      "title": "StartSlot",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "presence of the .floating-button__start-slot child"
      },
      "figma": {
        "kind": "variant-property",
        "property": "StartSlot",
        "notes": "Boolean visibility of the 🔴StartSlot swap slot. Off by default — so the opened form is an icon and a label only when switched on; in Component=Squircle the slot is the whole content."
      }
    },
    "endSlot": {
      "title": "EndSlot",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "presence of the .floating-button__end-slot child"
      },
      "figma": {
        "kind": "variant-property",
        "property": "EndSlot",
        "notes": "Boolean visibility of the 🔵EndSlot swap slot; off by default."
      }
    },
    "indicator": {
      "title": "Indicator",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "presence of the .floating-button__indicator overlay child"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Indicator",
        "notes": "Off by default. Exposed to assistive tech only when it says something the label does not; a decorative dot is aria-hidden."
      }
    },
    "title": {
      "title": "Title",
      "type": "text",
      "css": {
        "mechanism": "text of .floating-button__title-row"
      },
      "figma": {
        "kind": "none",
        "notes": "Text override on the StartText instance."
      },
      "customizable": "On the custom base the text style (Heading 4) is a default, replaceable by another DS text style; the presets lock it (spec §7).",
      "constraints": [
        "One line, truncating to an ellipsis — a label that would wrap belongs outside the control."
      ]
    },
    "subtitle": {
      "title": "Subtitle",
      "type": "text",
      "css": {
        "mechanism": "presence + text of .floating-button__subtitle-row"
      },
      "figma": {
        "kind": "none",
        "notes": "Optional second row of StartText."
      }
    }
  },
  "states": {
    "default": [
      "standard",
      "pressed",
      "disabled",
      "skeleton",
      "focus-visible"
    ]
  },
  "constraints": [
    "Not a Button instance and not composable from one: the component began as a customButton configuration promoted into a component of its own, which is why the slot semantics and the s12/s4/s8 padding contract match Button's exactly (spec §2). A control that sits in the flow of a screen is Button, whatever its shape.",
    "Nothing here is an instance of Squircle: the radius is s20 at both sizes, so L collapsed matches Squircle L exactly while M collapsed does NOT match Squircle M (s48 at s16) — the variant is named for the shape it reads as.",
    "Height is fixed per size (s56 / s48); collapsed the width equals the height with padding s0 — the square comes from the size, not from padding; opened the width hugs the label.",
    "The border is Border/Transparent at the DS hairline (1 — no SP token; the scale runs s0 → s2) drawn as an INSET box-shadow ring, never a layout border: a real border consumes layout and breaks the 16-to-icon / 20-to-label optical contract (spec §3).",
    "Elevation is --shadow-s on every Style and state; with the border it is what separates a floating control from whatever surface is behind it.",
    "The opening rides --transforming-state-slow (300ms, one catalogued step slower than a state flip): the control's width via interpolate-size: allow-keywords, the label's width and opacity on the same token; the icon never moves (16 from the leading edge in both forms). Browsers without interpolate-size — and reduced motion — snap between the forms.",
    "The press is pushButton (--component-push-button-press/-release), scale alone — no Style defines a pressed fill.",
    "The root is the focusable element and the only one, a native <button> with type=\"button\" (a bare <button> in a form defaults to submit).",
    "The accessible name does not change when the control opens — the aria-label stays; aria-expanded is deliberately absent (the Component axis is presentation, not disclosure).",
    "The focus ring is s2 inset in --text-and-icon-primary at radius s20 — except Inverse, which rings in --text-and-icon-inverse-primary (the default ring is the same primitive as the inverse fill in Dark).",
    "aria-disabled keeps a native button clickable: silencing Enter/Space in Disabled is the activation handler's job (the guard Checkbox and RadioButton carry).",
    "Opened Skeleton takes a fixed width instead of a hug: --sp-s120 at L. KNOWN GAP: the opened width at M is not captured from Figma yet — the web build leaves M hugging rather than approximating.",
    "One floating action per screen: two compete for the same corner and neither reads as the screen's action.",
    "A Floating Button that stays open permanently is a Button in the wrong place; the single screen-level action pinned to the bottom of a flow is Button's Main CTA.",
    "Figma identity: [FloatingButton] 3.3 — wrapper 16356:7288, sets 16356:5907 (L) / 16356:6925 (M). Stable componentKeys are not captured yet; capture them on the next Figma pass, since a node-id does not survive a library republish."
  ],
  "analytics": {
    "dataDsComponent": "floating-button",
    "action": "tap",
    "notes": "Coverage unit; the whole control is the only target in both forms, no internal targets. data-ds-preset custom | l | m (custom = the base configuration); data-ds-variant primary | error | inverse on the presets — the custom base may omit it, since its Style tokens are instance-replaced; data-ds-state standard | disabled | skeleton reports status, not a user value. The Component axis is NOT an analytics dimension — opening is presentation, and both forms report the same action."
  },
  "rtl": {
    "supported": true,
    "notes": "Opt-in through the component's own RTL axis (false by default): slots swap, directional icons flip, the Indicator pins top-left, the control opens toward the reading direction; the reveal anchor follows the text direction (flex-start)."
  }
}
```
