# Curve editor

A motion curve to shape by dragging its two handles, moving them with the arrow keys, or typing its four numbers.

Block: `smbk-curve-editor`. Status: experimental. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|

Parts: `smbk-curve-editor__plot`, `smbk-curve-editor__drawing`, `smbk-curve-editor__square`, `smbk-curve-editor__line`, `smbk-curve-editor__arm`, `smbk-curve-editor__limit`, `smbk-curve-editor__curve`, `smbk-curve-editor__handle`, `smbk-curve-editor__numbers`

## Markup

```html
<div role="group" aria-label="Enter easing" class="smbk-curve-editor">
  <div
    class="smbk-curve-editor__plot"
    dir="ltr"
  ><svg class="smbk-curve-editor__drawing" viewBox="0 0 240 240" focusable="false" aria-hidden="true"><rect class="smbk-curve-editor__square" x="40" y="56" width="160" height="160"></rect><line class="smbk-curve-editor__line" x1="40" y1="216" x2="200" y2="56"></line><line class="smbk-curve-editor__arm" x1="40" y1="216" x2="72" y2="216"></line><line class="smbk-curve-editor__arm" x1="200" y1="56" x2="40" y2="56"></line><path class="smbk-curve-editor__curve" d="M 40 216 C 72 216 40 56 200 56"></path></svg><div class="smbk-curve-editor__handle" style="left:30%;top:90%"><input type="range" min="0" max="1" step="0.01" tabindex="0" aria-label="First handle, time" aria-valuetext="time 0.2, movement 0" aria-orientation="horizontal" value="0.2"/><input type="range" min="-0.15" max="1.35" step="0.01" tabindex="-1" aria-label="First handle, movement" aria-valuetext="time 0.2, movement 0" aria-orientation="vertical" value="0"/></div><div class="smbk-curve-editor__handle" style="left:16.666666666666664%;top:23.333333333333332%"><input type="range" min="0" max="1" step="0.01" tabindex="0" aria-label="Second handle, time" aria-valuetext="time 0, movement 1" aria-orientation="horizontal" value="0"/><input type="range" min="-0.15" max="1.35" step="0.01" tabindex="-1" aria-label="Second handle, movement" aria-valuetext="time 0, movement 1" aria-orientation="vertical" value="1"/></div></div>
  <div class="smbk-curve-editor__numbers">
    <span class="smbk-input smbk-input--size-sm smbk-number-field smbk-number-field--size-sm">
      <input
        aria-label="x1, first handle’s time"
        aria-describedby="react-aria-curve-editor-1 react-aria-curve-editor-2"
        id="react-aria-curve-editor-3"
        type="text"
        autoComplete="off"
        inputMode="numeric"
        autoCorrect="off"
        spellCheck="false"
        tabindex="0"
        aria-roledescription="Number field"
        class="smbk-input__control smbk-number-field__control"
        value="0.2"
      >
    </span>
    <span class="smbk-input smbk-input--size-sm smbk-number-field smbk-number-field--size-sm">
      <input
        aria-label="y1, first handle’s movement"
        aria-describedby="react-aria-curve-editor-4 react-aria-curve-editor-5"
        id="react-aria-curve-editor-6"
        type="text"
        autoComplete="off"
        inputMode="numeric"
        autoCorrect="off"
        spellCheck="false"
        tabindex="0"
        aria-roledescription="Number field"
        class="smbk-input__control smbk-number-field__control"
        value="0"
      >
    </span>
    <span class="smbk-input smbk-input--size-sm smbk-number-field smbk-number-field--size-sm">
      <input
        aria-label="x2, second handle’s time"
        aria-describedby="react-aria-curve-editor-7 react-aria-curve-editor-8"
        id="react-aria-curve-editor-9"
        type="text"
        autoComplete="off"
        inputMode="numeric"
        autoCorrect="off"
        spellCheck="false"
        tabindex="0"
        aria-roledescription="Number field"
        class="smbk-input__control smbk-number-field__control"
        value="0"
      >
    </span>
    <span class="smbk-input smbk-input--size-sm smbk-number-field smbk-number-field--size-sm">
      <input
        aria-label="y2, second handle’s movement"
        aria-describedby="react-aria-curve-editor-10 react-aria-curve-editor-11"
        id="react-aria-curve-editor-12"
        type="text"
        autoComplete="off"
        inputMode="numeric"
        autoCorrect="off"
        spellCheck="false"
        tabindex="0"
        aria-roledescription="Number field"
        class="smbk-input__control smbk-number-field__control"
        value="1"
      >
    </span>
  </div>
</div>
```

## React

```tsx
import { CurveEditor } from './design-system/components/curve-editor/curve-editor.js';

<CurveEditor aria-label="Enter easing" defaultValue={[0.2, 0, 0, 1]} />
```

## Stylesheet

```css
/* smbk-curve-editor — Curve editor. Figma "Curve editor" 7416:192 (Type: Ease, Overshoot; Numbers switch) and its
 * "CurveHandle" 7416:121 (State: Default, Hover, Pressed, Focus).
 * A motion curve, time across and movement up: a square plot on the sunken surface inside a subtle edge holds the unit
 * square (a subtle line), the straight line from start to end (dashed, subtle), the two arms from the ends to the handles
 * (strong hairlines), the curve itself (two pixels in the brand action color) and, when the curve passes its end or its
 * start, a dashed strong line at how far it goes. Under it, four small number boxes give the same four numbers to type.
 * The drawing is an inline SVG whose shapes the wrapper places from the numbers; the lines' colors and widths are here.
 * The unit square sits as Figma places it, with room above it and less below, so a springy curve shows.
 * Each handle is a 12 px dot on the default surface with a two pixel brand ring and the control shadow, inside a 24 px
 * target. Hover lays the hover surface in it; pressed (data-pressed, while dragged) fills it with the brand color; when
 * one of its two hidden range inputs has keyboard focus it draws the focus ring outside a gap of the default surface. */

.smbk-curve-editor {
  display: flex;
  flex-direction: column;
  gap: var(--smbk-space-stack-md);
  width: 100%;
  max-width: var(--smbk-size-width-240);
  min-width: 0;
}

.smbk-curve-editor__plot {
  position: relative;
  box-sizing: border-box;
  width: 100%;
  aspect-ratio: 1;
  border: var(--smbk-border-width-hairline) solid var(--smbk-color-border-subtle);
  border-radius: var(--smbk-radius-control-md);
  background: var(--smbk-color-surface-sunken);
  touch-action: none;
}

.smbk-curve-editor__drawing {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.smbk-curve-editor__square,
.smbk-curve-editor__line,
.smbk-curve-editor__arm,
.smbk-curve-editor__limit,
.smbk-curve-editor__curve {
  fill: none;
  stroke-width: var(--smbk-border-width-hairline);
  vector-effect: non-scaling-stroke;
}

.smbk-curve-editor__square,
.smbk-curve-editor__line {
  stroke: var(--smbk-color-border-subtle);
}

.smbk-curve-editor__arm,
.smbk-curve-editor__limit {
  stroke: var(--smbk-color-border-strong);
}

.smbk-curve-editor__line,
.smbk-curve-editor__limit {
  stroke-dasharray: var(--smbk-size-4) var(--smbk-size-4);
}

.smbk-curve-editor__curve {
  stroke: var(--smbk-color-action-brand-background);
  stroke-width: var(--smbk-border-width-2);
  stroke-linecap: round;
}

/* ---- the handle (Figma's CurveHandle) ---- */

.smbk-curve-editor__handle {
  position: absolute;
  box-sizing: border-box;
  width: var(--smbk-size-12);
  height: var(--smbk-size-12);
  border: var(--smbk-border-width-2) solid var(--smbk-color-action-brand-background);
  border-radius: var(--smbk-radius-full);
  background: var(--smbk-color-surface-default);
  box-shadow: var(--smbk-elevation-control);
  transform: translate(-50%, -50%);
  cursor: grab;
  touch-action: none;
}

/* A 24 px target round the 12 px dot, so a finger or a pointer finds it. */
.smbk-curve-editor__handle::before {
  content: '';
  position: absolute;
  inset: calc((var(--smbk-size-12) - var(--smbk-size-24)) / 2 - var(--smbk-border-width-2));
  border-radius: var(--smbk-radius-full);
}

@media (hover: hover) {
  .smbk-curve-editor__handle:hover {
    background: var(--smbk-color-surface-hover);
  }
}

.smbk-curve-editor__handle[data-pressed] {
  background: var(--smbk-color-action-brand-background);
  cursor: grabbing;
}

/* Figma's focus style: the default surface in a gap, then the focus ring. The transparent outline is what forced
   colors mode paints, since it drops shadows. */
.smbk-curve-editor__handle:has(:focus-visible) {
  outline: var(--smbk-focus-ring-width) solid transparent;
  outline-offset: var(--smbk-focus-ring-offset);
  box-shadow:
    0 0 0 var(--smbk-focus-ring-offset) var(--smbk-color-surface-default),
    0 0 0 calc(var(--smbk-focus-ring-offset) + var(--smbk-focus-ring-width)) var(--smbk-color-border-focus);
}

/* The two hidden range inputs cover the dot, unseen; the handle takes the pointer. */
.smbk-curve-editor__handle input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

/* ---- the four numbers ---- */

.smbk-curve-editor__numbers {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--smbk-space-inline-sm);
}

/* ---- touch: a press anywhere inside a box lands on it ----
   On a touch screen each number box's text control reaches over the small well's padding at both ends, so the
   whole box takes the press, not only the figures; the four boxes stand apart by the row's gap. The figures stay
   where they are drawn. */
@media (pointer: coarse) {
  .smbk-curve-editor__numbers .smbk-input__control {
    margin-inline: calc(-1 * var(--smbk-space-inset-md));
    padding-inline: var(--smbk-space-inset-md);
  }
}

/* ---- high contrast (forced colors) ---- */
@media (forced-colors: active) {
  .smbk-curve-editor__square,
  .smbk-curve-editor__line,
  .smbk-curve-editor__arm,
  .smbk-curve-editor__limit {
    stroke: GrayText;
  }

  .smbk-curve-editor__curve {
    stroke: CanvasText;
  }

  .smbk-curve-editor__handle {
    border-color: CanvasText;
    background: Canvas;
  }

  .smbk-curve-editor__handle[data-pressed] {
    background: Highlight;
  }

  .smbk-curve-editor__handle:has(:focus-visible) {
    outline-color: Highlight;
  }
}
```

## Tokens it reads

```json
{
  "space": {
    "stack": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.12}}",
        "$description": "Gap between things stacked one above another, medium."
      }
    },
    "inline": {
      "sm": {
        "$type": "dimension",
        "$value": "{{space.6}}",
        "$description": "Gap between things side by side, small."
      }
    },
    "inset": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Padding inside a component, medium."
      }
    }
  },
  "size": {
    "4": {
      "$type": "dimension",
      "$value": "4px",
      "$description": "Raw size step, 4 px. Only for fixed sizes inside a component; semantic sizes (size/control, icon, mark) point here."
    },
    "12": {
      "$type": "dimension",
      "$value": "12px",
      "$description": "Raw size step, 12 px. Only for fixed sizes inside a component; semantic sizes (size/control, icon, mark) point here."
    },
    "24": {
      "$type": "dimension",
      "$value": "24px",
      "$description": "Raw size step, 24 px. Only for fixed sizes inside a component; semantic sizes (size/control, icon, mark) point here."
    },
    "width": {
      "240": {
        "$type": "dimension",
        "$value": "240px",
        "$description": "A fixed width of 240 px: a narrow column, a list’s greatest height. Semantic sizes point here."
      }
    }
  },
  "border-width": {
    "2": {
      "$type": "dimension",
      "$value": "2px",
      "$description": "Raw line thickness, 2 px. border-width/hairline, emphasis and accent point here."
    },
    "hairline": {
      "$type": "dimension",
      "$value": "{{border-width.1}}",
      "$description": "Thin lines: dividers and almost every edge."
    }
  },
  "color": {
    "border": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.300}}",
        "$description": "Dividers and quiet edges."
      },
      "strong": {
        "$type": "color",
        "$value": "{{color.grey.400}}",
        "$description": "Stronger edges and separators."
      },
      "focus": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Keyboard focus ring on controls."
      }
    },
    "surface": {
      "sunken": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.5}}",
        "$description": "Recessed wells: fields, tracks, code. See-through, so a well always shows one step below what it sits on: the page, a card or a panel."
      },
      "default": {
        "$type": "color",
        "$value": "{{color.grey.100}}",
        "$description": "Standard surface: cards, panels, the header."
      },
      "hover": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.15}}",
        "$description": "A row or option under the pointer or the keyboard highlight: menu options, table rows. Translucent, so it works on any surface."
      }
    },
    "action": {
      "brand": {
        "background": {
          "$type": "color",
          "$value": "{{color.red.600}}",
          "$description": "Brand button and chip: fill at rest."
        }
      }
    }
  },
  "radius": {
    "control": {
      "md": {
        "$type": "dimension",
        "$value": "{{radius.6}}",
        "$description": "Corners of controls, medium."
      }
    },
    "full": {
      "$type": "dimension",
      "$value": "{{radius.9999}}",
      "$description": "Capsule: pills, chips, switches."
    }
  },
  "elevation": {
    "control": {
      "$type": "shadow",
      "$value": {
        "light": "0px 0px 1px 0px #373d430d, 0px 1px 2px 0px #373d431a, 0px 2px 4px 0px #373d430d",
        "dark": "0px 0px 1px 0px #0000001a, 0px 1px 2px 0px #00000026, 0px 2px 4px 0px #0000001a"
      }
    }
  },
  "focus": {
    "ring": {
      "width": {
        "$type": "dimension",
        "$value": "{{border-width.2}}",
        "$description": "Thickness of the focus ring."
      },
      "offset": {
        "$type": "dimension",
        "$value": "{{space.2}}",
        "$description": "Gap between a control and its focus ring."
      }
    }
  }
}
```

## Purpose

Shapes a motion curve, the easing of an animation: time runs across and movement up, from the start in the lower left to the end in the upper right. The reader drags its two handles, moves them with the arrow keys, or types the four numbers CSS's `cubic-bezier()` takes. It is for tools that edit a design system's motion. To choose among a few named easings, use a select or a segmented control.

## Anatomy

- The block: a column of the plot over the numbers. It fills its width up to 240 px.
- The plot part: a square on the sunken surface. Its drawing holds the unit square (the square part), the straight line from start to end (the line part, dashed), the two arms from the ends to the handles (the arm part), the curve (the curve part, in the brand color) and, when the curve goes past its end or before its start, a dashed line at how far it goes (the limit part).
- The handle part, two of them: a 12 px dot with a brand ring, inside a 24 px target. Hover fills it with the hover surface; while it is dragged it fills with the brand color; with keyboard focus it shows the focus ring.
- The numbers part: four small number boxes, the first handle's time and movement, then the second's. They can be left out.

## When to use

- To set or adjust the easing of a motion token or an animation.

## When not to use

- To pick one of a few named easings. Offer them by name.
- For anything but a cubic curve: steps and springs are not curves of two handles.

## Behavior

A handle follows the pointer while it is dragged. Its time stays between 0 and 1, as CSS needs; its movement may go past either end, a springy curve that overshoots, as far as the plot's room above and below the square. The editor holds its curve from a first value, or follows the one the page gives it, and reports the four numbers after every move and every settled number. `curveToCss` writes them as CSS.

On a touch screen a press anywhere inside a number box lands on it, not only on its figures.

## Accessibility

### What it does for you

- The editor is a group. Each handle holds two range inputs, its time and its movement, named "First handle, time" and so on, and each says both values ("time 0.2, movement 0"). One of the two is the handle's stop in the Tab order.
- The keys move a handle: Left and Right move the time, Up and Down the movement, by 0.01, or 0.1 with Shift. Page Up and Page Down move the movement by 0.1; Home and End put the time at 0 and 1.
- The four number boxes give the same values to type, each named for its number ("x1, first handle's time"), and step with the up and down arrow keys. Dragging is never the only way.
- The drawing is hidden from screen readers; the inputs and the boxes are the content. In forced colors mode the curve and the handles take the reader's colors.
- On a touch screen a press anywhere inside a number box lands on it, not only on its figures.

### What you must do

- Name the editor for the motion it shapes, such as "Enter easing".
- Keep the number boxes where the reader needs the exact curve, or has nowhere else to read or type it.
- Give every word it says by itself, such as the handles' and the number boxes' names, in the page's language; each keeps its English unless the page gives its own.

### How to test

- Tab to a handle and move it with the arrow keys, with and without Shift, then with Page Up, Page Down, Home and End. The values change and the curve follows.
- Type a number in each box and step it with Up Arrow and Down Arrow.
- With a screen reader, the editor is read as a group by its name, each handle by its name and both values, each box by its number.
- With text at 200% and the page 320 pixels wide, the boxes and the plot stay whole.
- In a forced-color mode, the curve and the handles are visible.

### Keyboard

| Key | What it does |
|---|---|
| Tab or Shift + Tab | Moves focus to the next or previous handle or number box. |
| Left Arrow or Right Arrow | Moves the focused handle's time by 0.01, down or up. With Shift, by 0.1. |
| Up Arrow or Down Arrow | Moves the focused handle's movement by 0.01, up or down. With Shift, by 0.1. In a number box, steps the number. |
| Page Up or Page Down | Moves the focused handle's movement by 0.1, up or down. |
| Home or End | Puts the focused handle's time at 0 or at 1. |

## Usage rules

### Do

- Name the editor for the motion it shapes.
- Keep the numbers where the reader needs the exact curve.

### Don't

- Leave out the numbers where the reader has nowhere else to read or type the curve.
