# Ruler

A width ruler for a preview: the layout states and breakpoints along one band, the screen widths, and a handle for the width now.

Block: `smbk-ruler`. 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-ruler__scale`, `smbk-ruler__band`, `smbk-ruler__zone`, `smbk-ruler__break`, `smbk-ruler__tick`, `smbk-ruler__handle`, `smbk-ruler__widths`, `smbk-ruler__width`, `smbk-ruler__now`, `smbk-ruler__controls`, `smbk-ruler__fit`, `smbk-ruler__hide`

## Markup

```html
<div class="smbk-ruler">
  <div class="smbk-ruler__scale">
    <div class="smbk-ruler__band"><span class="smbk-ruler__zone" style="inset-inline-start:0%;width:48%" aria-hidden="true">Narrow</span><span class="smbk-ruler__zone" style="inset-inline-start:48%;width:32%" aria-hidden="true">Wide</span><span class="smbk-ruler__zone" style="inset-inline-start:80%;width:20%" aria-hidden="true">Side columns</span><span class="smbk-ruler__break" style="inset-inline-start:48%" aria-hidden="true"></span><span class="smbk-ruler__break" style="inset-inline-start:80%" aria-hidden="true"></span><span class="smbk-ruler__tick" style="inset-inline-start:20%" aria-hidden="true"></span><span class="smbk-ruler__tick" style="inset-inline-start:40%" aria-hidden="true"></span><span class="smbk-ruler__tick" style="inset-inline-start:64%" aria-hidden="true"></span><span class="smbk-ruler__tick" style="inset-inline-start:90%" aria-hidden="true"></span><div role="slider" tabindex="0" aria-label="Preview width" aria-valuemin="320" aria-valuemax="1600" aria-valuenow="1024" aria-valuetext="1024 pixels, wide" aria-orientation="horizontal" class="smbk-ruler__handle" style="inset-inline-start:64%"></div></div>
    <div class="smbk-ruler__widths"><button class="smbk-ruler__width" style="inset-inline-start:20%" type="button" aria-pressed="false" aria-label="320 pixels">320</button><button class="smbk-ruler__width" style="inset-inline-start:40%" type="button" aria-pressed="false" aria-label="640 pixels">640</button><button class="smbk-ruler__width" style="inset-inline-start:48%" data-breakpoint="true" type="button" aria-pressed="false" aria-label="768 pixels">768</button><button class="smbk-ruler__width" style="inset-inline-start:64%" type="button" aria-pressed="true" aria-label="1024 pixels">1024</button><button class="smbk-ruler__width" style="inset-inline-start:80%" data-breakpoint="true" type="button" aria-pressed="false" aria-label="1280 pixels">1280</button><button class="smbk-ruler__width" style="inset-inline-start:90%" type="button" aria-pressed="false" aria-label="1440 pixels">1440</button></div>
  </div>
  <span class="smbk-ruler__now" aria-hidden="true">1024 px</span>
</div>
```

## React

```tsx
import { Ruler } from './design-system/components/ruler/ruler.js';

<Ruler zones={[{ name: "Narrow", from: 0 }, { name: "Wide", from: 768 }, { name: "Side columns", from: 1280 }]} widths={[320, 640, 1024, 1440]} max={1600} defaultValue={1024} />
```

## Stylesheet

```css
/* smbk-ruler — Ruler. Figma "Ruler" 7417:374 (State: Default, Focus; Widths switch).
 * A width ruler for a preview: one band from nothing to the widest width, 24 tall on the sunken surface inside a subtle
 * edge, shaded by layout state (the first state plain, each later one on the subtle neutral surface, named inside it in
 * Label SM), the breakpoints as full strong hairlines and the screen widths as short ticks at its foot. Under it, the
 * widths in Body XS. The handle is the width now, a 4 px brand bar standing 4 px out of the band at both ends; when it
 * has keyboard focus it draws the focus ring. Beside the band: the width now in Label MD, then the Fit and Hide icon
 * buttons. Places along the band are the wrapper's, as percentages of its length, set with logical properties so a
 * right-to-left page reads it from the right. In a narrow space the band takes the whole row, the width and the buttons
 * go under it, and only the screen widths keep their numbers. */

.smbk-ruler {
  display: flex;
  align-items: center;
  gap: var(--smbk-space-inline-md);
  min-width: 0;
}

.smbk-ruler__scale {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: var(--smbk-space-stack-xs);
  min-width: 0;
}

.smbk-ruler__band {
  position: relative;
  box-sizing: border-box;
  height: var(--smbk-size-24);
  border: var(--smbk-border-width-hairline) solid var(--smbk-color-border-subtle);
  border-radius: var(--smbk-radius-control-sm);
  background: var(--smbk-color-surface-sunken);
  touch-action: none;
  cursor: pointer;
}

/* A layout state's stretch of the band; each after the first is shaded. Its name sits inside it and is cut off at its
   end rather than running into the next. */
.smbk-ruler__zone {
  position: absolute;
  inset-block: 0;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  overflow: hidden;
  padding-inline-start: var(--smbk-space-inset-sm);
  color: var(--smbk-color-text-subtle);
  font-family: var(--smbk-typography-label-sm-font-family);
  font-size: var(--smbk-typography-label-sm-font-size);
  font-weight: var(--smbk-typography-label-sm-font-weight);
  line-height: var(--smbk-typography-label-sm-line-height);
  letter-spacing: var(--smbk-typography-label-sm-letter-spacing);
  white-space: nowrap;
  pointer-events: none;
}

.smbk-ruler__zone + .smbk-ruler__zone {
  background: var(--smbk-color-surface-neutral-subtle);
}

/* The last stretch takes the band's own corner at its end. */
.smbk-ruler__zone:last-of-type:not(:first-of-type) {
  border-start-end-radius: var(--smbk-radius-control-sm);
  border-end-end-radius: var(--smbk-radius-control-sm);
}

/* A breakpoint: a full strong hairline. A screen width: a short tick at the band's foot. */
.smbk-ruler__break,
.smbk-ruler__tick {
  position: absolute;
  width: var(--smbk-border-width-hairline);
  background: var(--smbk-color-border-strong);
  transform: translateX(-50%);
  pointer-events: none;
}

.smbk-ruler__break {
  inset-block: 0;
}

.smbk-ruler__tick {
  bottom: 0;
  height: var(--smbk-size-6);
}

[dir="rtl"] .smbk-ruler__break,
[dir="rtl"] .smbk-ruler__tick,
[dir="rtl"] .smbk-ruler__handle,
[dir="rtl"] .smbk-ruler__width {
  transform: translateX(50%);
}

/* The handle: a brand bar standing out of the band at both ends, inside a 24 px wide target. */
.smbk-ruler__handle {
  position: absolute;
  inset-block: calc((var(--smbk-size-24) - var(--smbk-size-32)) / 2 - var(--smbk-border-width-hairline));
  box-sizing: border-box;
  width: var(--smbk-size-4);
  border-radius: var(--smbk-radius-full);
  background: var(--smbk-color-action-brand-background);
  transform: translateX(-50%);
  cursor: ew-resize;
  outline: none;
}

.smbk-ruler__handle::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline: calc((var(--smbk-size-4) - var(--smbk-size-24)) / 2);
}

/* 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-ruler__handle: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 widths under the band, each centred under its place. */
.smbk-ruler__widths {
  position: relative;
  height: calc(var(--smbk-typography-body-xs-font-size) * var(--smbk-typography-body-xs-line-height));
}

.smbk-ruler__width {
  position: absolute;
  top: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--smbk-color-text-subtlest);
  font-family: var(--smbk-typography-body-xs-font-family);
  font-size: var(--smbk-typography-body-xs-font-size);
  font-weight: var(--smbk-typography-body-xs-font-weight);
  line-height: var(--smbk-typography-body-xs-line-height);
  letter-spacing: var(--smbk-typography-body-xs-letter-spacing);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  transform: translateX(-50%);
}

button.smbk-ruler__width {
  cursor: pointer;
  border-radius: var(--smbk-radius-control-xs);
  outline: none;
}

/* A 24 px tall target round a width's figures. */
button.smbk-ruler__width::before {
  content: '';
  position: absolute;
  inset-block: calc((var(--smbk-typography-body-xs-font-size) * var(--smbk-typography-body-xs-line-height) - var(--smbk-size-24)) / 2);
  inset-inline: calc(-1 * var(--smbk-space-inline-xs));
}

@media (hover: hover) {
  button.smbk-ruler__width:hover {
    color: var(--smbk-color-text-default);
  }
}

button.smbk-ruler__width[aria-pressed="true"] {
  color: var(--smbk-color-text-default);
}

button.smbk-ruler__width:focus-visible {
  outline: var(--smbk-focus-ring-width) solid var(--smbk-color-border-focus);
  outline-offset: var(--smbk-focus-ring-offset);
}

.smbk-ruler__now {
  flex-shrink: 0;
  color: var(--smbk-color-text-default);
  font-family: var(--smbk-typography-label-md-font-family);
  font-size: var(--smbk-typography-label-md-font-size);
  font-weight: var(--smbk-typography-label-md-font-weight);
  line-height: var(--smbk-typography-label-md-line-height);
  letter-spacing: var(--smbk-typography-label-md-letter-spacing);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.smbk-ruler__controls {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: var(--smbk-space-inline-md);
}

/* ---- the narrow form: the band takes the row; the width and the buttons go under it; the breakpoints keep their lines
   but not their numbers, which would run into the screen widths' ---- */
@container style(--smbk-layout: narrow) {
  .smbk-ruler {
    flex-wrap: wrap;
    row-gap: var(--smbk-space-stack-sm);
  }

  .smbk-ruler__scale {
    flex-basis: 100%;
  }

  .smbk-ruler__width[data-breakpoint] {
    display: none;
  }
}

/* ---- touch: the band, the widths and the buttons take invisible press areas ----
   On a touch screen the band, which a finger drags along, takes an invisible area out to the comfortable target above
   it, and below it only halfway to the widths; each width takes one out to the target, toward the band only halfway;
   the buttons take theirs, side to side only halfway to each other. In the narrow form, where the widths and the
   buttons wrap under the band, each reaches only halfway into the gaps between them. What is drawn stays as it is. */

@media (pointer: coarse) {
  .smbk-ruler__band::before {
    content: '';
    position: absolute;
    inset-inline: 0;
    inset-block: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
  }

  .smbk-ruler__scale:has(button.smbk-ruler__width) .smbk-ruler__band::before {
    inset-block-end: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-xs) / -2);
  }

  button.smbk-ruler__width::before {
    inset-inline: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
    inset-block-start: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-xs) / -2);
    inset-block-end: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
  }

  .smbk-ruler__controls .smbk-btn::after {
    inset-inline: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-inline-md) / -2);
  }
}

@container style(--smbk-layout: narrow) {
  @media (pointer: coarse) {
    .smbk-ruler__band::before {
      inset-block: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-sm) / -2);
    }

    .smbk-ruler__scale:has(button.smbk-ruler__width) .smbk-ruler__band::before {
      inset-block-end: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-xs) / -2);
    }

    button.smbk-ruler__width::before {
      inset-inline: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-inline-md) / -2);
      inset-block-end: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-sm) / -2);
    }

    .smbk-ruler__controls .smbk-btn::after {
      inset-block: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-stack-sm) / -2);
    }
  }
}

/* ---- high contrast (forced colors) ---- */
@media (forced-colors: active) {
  .smbk-ruler__band {
    border-color: CanvasText;
  }

  .smbk-ruler__break,
  .smbk-ruler__tick {
    background: CanvasText;
  }

  .smbk-ruler__handle {
    background: Highlight;
  }

  .smbk-ruler__handle:focus-visible {
    outline-color: CanvasText;
  }
}
```

## Tokens it reads

```json
{
  "space": {
    "inline": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things side by side, medium."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things side by side, extra small."
      }
    },
    "stack": {
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things stacked one above another, extra small."
      },
      "sm": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things stacked one above another, small."
      }
    },
    "inset": {
      "sm": {
        "$type": "dimension",
        "$value": "{{space.6}}",
        "$description": "Padding inside a component, small."
      }
    }
  },
  "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."
    },
    "6": {
      "$type": "dimension",
      "$value": "6px",
      "$description": "Raw size step, 6 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."
    },
    "32": {
      "$type": "dimension",
      "$value": "32px",
      "$description": "Raw size step, 32 px. Only for fixed sizes inside a component; semantic sizes (size/control, icon, mark) point here."
    },
    "target": {
      "comfortable": {
        "$type": "dimension",
        "$value": "{{size.44}}",
        "$description": "Comfortable touch target (44)."
      }
    }
  },
  "border-width": {
    "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."
      },
      "neutral-subtle": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.10}}",
        "$description": "A quiet neutral fill for small marks and their resting state (default badge, tag, chip): translucent, so it shows by the same step on the default, raised and sunken surfaces. Hover steps up to color/surface/hover."
      },
      "default": {
        "$type": "color",
        "$value": "{{color.grey.100}}",
        "$description": "Standard surface: cards, panels, the header."
      }
    },
    "text": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Secondary text: descriptions, metadata."
      },
      "subtlest": {
        "$type": "color",
        "$value": "{{color.grey.600}}",
        "$description": "Quietest text: hints, captions, placeholders."
      },
      "default": {
        "$type": "color",
        "$value": "{{color.grey.900}}",
        "$description": "Main text."
      }
    },
    "action": {
      "brand": {
        "background": {
          "$type": "color",
          "$value": "{{color.red.600}}",
          "$description": "Brand button and chip: fill at rest."
        }
      }
    }
  },
  "radius": {
    "control": {
      "sm": {
        "$type": "dimension",
        "$value": "{{radius.4}}",
        "$description": "Corners of controls, small."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{radius.2}}",
        "$description": "Corners of controls, extra small."
      }
    },
    "full": {
      "$type": "dimension",
      "$value": "{{radius.9999}}",
      "$description": "Capsule: pills, chips, switches."
    }
  },
  "typography": {
    "label-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 500,
        "lineHeight": 1.4,
        "letterSpacing": "0px"
      },
      "$description": "Label SM: labels on controls and short UI text (buttons, chips, tabs, menu headings). Geist Medium, sentence case. Code and keys use Code SM and Code MD."
    },
    "body-xs": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    },
    "label-md": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 500,
        "lineHeight": 1.4,
        "letterSpacing": "0px"
      },
      "$description": "Label MD: labels on controls and short UI text (buttons, chips, tabs, menu headings). Geist Medium, sentence case. Code and keys use Code SM and Code MD."
    }
  },
  "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

Sets and shows the width of a preview: one band from nothing to the widest width, shaded by layout state, with the breakpoints, the common screen widths and a handle for the width now. It is for tools that preview pages or components at different widths. To set a width that has nothing to do with layout, use a slider.

## Anatomy

- The block: a row of the scale, the width now and the buttons. In a narrow space the scale takes the whole row and the rest goes under it.
- The scale part: the band over the widths.
- The band part, 24 px tall on the sunken surface: one zone part per layout state, each after the first shaded and named inside its stretch; a full line (the break part) at each breakpoint; a short tick (the tick part) at each screen width; and the handle part, a brand bar at the width now.
- The widths part: the numbers under the band, each a width part. They can be left out. In a narrow space only the screen widths keep their numbers.
- The now part: the width now, such as "1024 px".
- The controls part: Fit, a toggle that makes the preview fill the space (the fit part), and Hide (the hide part). Each shows only when the page handles it.

## When to use

- Above a preview whose width the reader sets, to show which layout state each width falls in.

## When not to use

- For a value that is not a width in pixels. Use a [slider](slider.md).

## Behavior

The page gives the layout states, each from the width it starts at, the screen widths to mark and the width at the far end; pass the system's own breakpoints so the states match the pages'. The ruler holds the width from a first value, or follows the one the page gives it, and reports each new width. A press on the band moves the handle there and dragging carries it; a press on a number sets that width. The handle stays between the narrowest screen width, or the least given, and the far end. Places along the band are set with logical properties, so a right-to-left page reads it from the right.

On a touch screen the band, each width and the buttons keep their look and take an invisible press area, out to the comfortable touch target where there is room and halfway to each other where they stand closer.

## Accessibility

### What it does for you

- The handle is a slider named "Preview width". It says its value in pixels with its layout state: "768 pixels, wide".
- The numbers under the band are buttons named in pixels ("320 pixels"); the one at the width now is pressed.
- Fit is a toggle button named "Fit the space"; Hide is named "Hide the ruler".
- The layout state is said in words, not told by shading alone.
- In forced colors mode the band's lines and the handle take the reader's colors.
- Every word it says by itself, such as the names of the widths, Fit and Hide, keeps its English unless the page gives its own.
- Dragging is never the only way: the keys move the handle too.
- On a touch screen the band, each width and the buttons take an invisible press area, out to the comfortable touch target where there is room and halfway to each other where they stand closer.

### What you must do

- Give the names of the widths, Fit and Hide in the page's own language when the page is not in English.
- Give it the system's own breakpoints, so the layout states it says in words match the pages'.

### How to test

- Tab to the handle: the focus ring shows. Press Left Arrow and Right Arrow: the width changes by 10 pixels, by 1 with Shift held; Page Up and Page Down change it by 100, and Home and End go to its ends.
- With a screen reader, the handle is read as a slider named "Preview width", with its value in pixels and its layout state.
- Press a number under the band: the handle moves to that width, and the number is read as pressed.
- With text at 200% and the page 320 pixels wide, the numbers and buttons stay reachable.
- In a forced-color mode, the band's lines and the handle are visible in the reader's colors.

### Keyboard

| Key | What it does |
|---|---|
| Tab | Moves focus to the handle, then to the width buttons, Fit and Hide. |
| Left Arrow or Right Arrow | Moves the handle 10 pixels, or 1 with Shift. On a right-to-left page the directions swap. |
| Up Arrow or Down Arrow | Moves the handle wider or narrower by 10 pixels, or 1 with Shift. |
| Page Up or Page Down | Moves the handle 100 pixels. |
| Home or End | Moves the handle to its narrowest or widest end. |
| Enter or Space | On a width button, Fit or Hide, presses it. |

## Usage rules

### Do

- Give it the system's own breakpoints.
- Put it directly above the preview it sets.

### Don't

- Make up breakpoints of its own, which shows states no page has.
