# Segmented Control

> Equal-width segments with a sliding thumb, as a radio group.

- **Slug:** `segmented-control`
- **Kind:** atom · **Category:** Inputs
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/SegmentedControl.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/segmented-control
- **All files in one bundle:** https://viewership.ai/ui/components/segmented-control.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/segmented-control.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/segmented-control/`:

   - `SegmentedControl.astro` (raw: https://viewership.ai/ui/raw/segmented-control/SegmentedControl.astro.txt)

npm packages: none.

## Usage

```astro
---
import SegmentedControl from "../components/viewership/segmented-control/SegmentedControl.astro";
---

<SegmentedControl />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import SegmentedControl from "./SegmentedControl.astro";

const ranges = {
  Day: { kg: "64", note: "4 new since 6:30 am" },
  Week: { kg: "412", note: "27 new across 9 topics" },
  Month: { kg: "1,780", note: "112 new, 18 cited pages" },
};
---

<div class="card" data-seg-demo data-ranges={JSON.stringify(ranges)}>
  <div class="head">
    <span class="title">AI citations</span>
    <SegmentedControl label="Range" options={Object.keys(ranges)} value="Week" />
  </div>
  <p class="stat" aria-live="polite">
    <span class="kg" data-kg>{ranges.Week.kg}</span><span class="unit">cites</span>
    <span class="note" data-note>{ranges.Week.note}</span>
  </p>
  <div class="demo-foot">
    <span class="demo-label">Engine</span>
    <SegmentedControl label="Engine" options={[{ value: "chatgpt", label: "ChatGPT" }, { value: "perplexity", label: "Perplexity" }, { value: "gemini", label: "Gemini" }]} name="engine" />
  </div>
</div>

<script>
  const boot = () =>
    document.querySelectorAll<HTMLElement>("[data-seg-demo]:not([data-vs-ready])").forEach((demo) => {
      demo.dataset.vsReady = "";
      const ranges = JSON.parse(demo.dataset.ranges ?? "{}") as Record<string, { kg: string; note: string }>;
      const kg = demo.querySelector("[data-kg]");
      const note = demo.querySelector("[data-note]");
      demo.querySelector(".head")?.addEventListener("vs-change", (event) => {
        const range = ranges[(event as CustomEvent<{ value: string }>).detail.value];
        if (!range || !kg || !note) return;
        kg.textContent = range.kg;
        note.textContent = range.note;
      });
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .card {
    max-width: 380px;
    margin: 0 auto;
    padding: 16px 18px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
  }
  .title {
    font-size: 13px;
    font-weight: 600;
    color: var(--vs-ink);
  }
  .stat {
    margin: 14px 0 16px;
    color: var(--vs-ink);
  }
  .kg {
    font-size: 30px;
    font-weight: 600;
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
  }
  .unit {
    margin-left: 4px;
    font-size: 14px;
    color: var(--vs-ink-2);
  }
  .note {
    display: block;
    margin-top: 2px;
    font-size: 12px;
    color: var(--vs-ink-3);
  }
  .demo-foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding-top: 14px;
    border-top: 1px solid var(--vs-line);
  }
  .demo-label {
    font-size: 13px;
    color: var(--vs-ink-2);
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `(string \| { value: string; label: string })[]` |  | The segments, in order. |
| `value` | `string` |  | Selected value (default: first). |
| `label` | `string` |  | Accessible group name, or use aria-labelledby. |
| `name` | `string` |  | Form field name. Adds a hidden input. |
| `vs-change` | `CustomEvent<{ value: string; index: number }>` |  | Fires on selection. Root mirrors data-value. |

## Source

### `src/components/viewership/segmented-control/SegmentedControl.astro`

```astro
---
/**
 * SegmentedControl: equal-width segments with a sliding thumb.
 *
 * Ported from Beautiful UI `components/atoms/SegmentedControl.tsx`
 * (MIT, © 2026 Shane Levine). This is a WAI-ARIA radio group, not tabs:
 * one tab stop, arrow keys move and select, Home/End jump to the ends.
 *
 * The root fires a bubbling `vs-change` event with `detail: { value, index }`
 * and mirrors the value on `data-value`. Call `.click()` on an option to
 * select from script. Pass `name` to submit the value with a form.
 *
 * @example
 * <SegmentedControl label="Range" options={["Day", "Week", "Month"]} value="Week" />
 */
import type { HTMLAttributes } from "astro/types";

type Option = string | { value: string; label: string };

type Props = HTMLAttributes<"div"> & {
  /** Options, as plain strings or { value, label }. */
  options: Option[];
  /** Selected value. Defaults to the first option. */
  value?: string;
  /** Accessible name for the group (or pass aria-labelledby). */
  label?: string;
  /** Form field name; adds a hidden input carrying the value. */
  name?: string;
};

const { options, value, label, name, class: className, style, ...rest } = Astro.props;
const items = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
const found = items.findIndex((o) => o.value === value);
const index = found === -1 ? 0 : found;
const rootStyle = [`--vs-seg-count: ${items.length}; --vs-seg-index: ${index}`, typeof style === "string" ? style : ""]
  .filter(Boolean)
  .join("; ");
---

<div
  class:list={["vs-segmented", className]}
  role="radiogroup"
  aria-label={label}
  data-value={items[index]?.value}
  style={rootStyle}
  {...rest}
>
  <span class="vs-segmented__thumb" aria-hidden="true"></span>
  {items.map((o, i) => (
    <button
      type="button"
      role="radio"
      class="vs-segmented__option"
      aria-checked={i === index ? "true" : "false"}
      tabindex={i === index ? 0 : -1}
      data-value={o.value}
    >
      {o.label}
    </button>
  ))}
  {name && <input type="hidden" name={name} value={items[index]?.value} />}
</div>

<script>
  function init(root: HTMLElement) {
    const options = () => Array.from(root.querySelectorAll<HTMLButtonElement>(".vs-segmented__option"));
    const input = root.querySelector<HTMLInputElement>("input[type=hidden]");

    const select = (index: number, focus: boolean) => {
      const list = options();
      const next = list[index];
      if (!next) return;
      if (focus) next.focus();
      if (next.getAttribute("aria-checked") === "true") return;
      list.forEach((option, i) => {
        option.setAttribute("aria-checked", String(i === index));
        option.tabIndex = i === index ? 0 : -1;
      });
      const value = next.dataset.value ?? "";
      root.style.setProperty("--vs-seg-index", String(index));
      root.dataset.value = value;
      if (input) input.value = value;
      root.dispatchEvent(new CustomEvent("vs-change", { bubbles: true, detail: { value, index } }));
    };

    root.addEventListener("click", (event) => {
      const option = (event.target as Element).closest<HTMLButtonElement>(".vs-segmented__option");
      if (option) select(options().indexOf(option), false);
    });

    root.addEventListener("keydown", (event) => {
      const list = options();
      const i = list.indexOf(event.target as HTMLButtonElement);
      if (i === -1) return;
      const last = list.length - 1;
      let next: number | null = null;
      if (event.key === "ArrowRight" || event.key === "ArrowDown") next = i === last ? 0 : i + 1;
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = i === 0 ? last : i - 1;
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = last;
      if (next === null) return;
      event.preventDefault();
      select(next, true);
    });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-segmented:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-segmented {
    position: relative;
    display: inline-grid;
    grid-template-columns: repeat(var(--vs-seg-count), 1fr);
    height: 32px;
    padding: 2px;
    box-sizing: border-box;
    border-radius: var(--vs-radius-pill);
    background: color-mix(in oklab, var(--vs-line) 60%, transparent);
    user-select: none;
  }
  .vs-segmented__thumb {
    position: absolute;
    top: 2px;
    bottom: 2px;
    left: 2px;
    width: calc((100% - 4px) / var(--vs-seg-count));
    border-radius: var(--vs-radius-pill);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-hairline);
    transform: translateX(calc(var(--vs-seg-index) * 100%));
    transition: transform 200ms var(--vs-ease-out);
  }
  .vs-segmented__option {
    position: relative;
    z-index: 1;
    min-width: 0;
    padding: 0 12px;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-3);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    white-space: nowrap;
    cursor: pointer;
    transition: color var(--vs-duration-fast) ease-out;
  }
  .vs-segmented__option:hover {
    color: var(--vs-ink-2);
  }
  .vs-segmented__option[aria-checked="true"] {
    color: var(--vs-ink);
  }
  .vs-segmented__option:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  @media (prefers-reduced-motion: reduce) {
    .vs-segmented__thumb {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .vs-segmented__thumb {
      outline: 2px solid Highlight;
    }
  }
</style>
```
