AI citations
412cites 27 new across 9 topics
Engine
Equal-width segments with a sliding thumb, as a radio group.
InputsAtomNo dependencies Beautiful UI, MIT
412cites 27 new across 9 topics
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- Paste into src/components/viewership/segmented-control/
---
import SegmentedControl from "../components/viewership/segmented-control/SegmentedControl.astro";
---
<SegmentedControl /> | 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. |
| Event | Detail | When |
|---|---|---|
vs-change | CustomEvent<{ value: string; index: number }> | Fires on selection. Root mirrors data-value. |
---
/**
* 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> Ported from Beautiful UI, MIT. Notices.