Actions
Segmented
Single-choice control that folds into a dropdown when it can't fit
Preview
Narrow turns on with auto, the only mode that changes with the width.
size
mode
options
Code
import css from "./Segmented.module.css";
import { useRef } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import Popover from "@/components/library/react/Popover/Popover";
type Size = "xs" | "sm" | "md";
// Three values, not two booleans: "forced dropdown that also collapses" must not be expressible.
type Mode = "pills" | "dropdown" | "auto";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SegmentedProps {
options: string[];
value: string;
size: Size | ResponsiveSize;
mode: Mode;
// Required: collapsed, the trigger shows only the value, nothing says of what.
"aria-label": string;
onChange: (value: string) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Segmented({
options,
value,
size,
mode,
"aria-label": ariaLabel,
onChange,
}: SegmentedProps) {
const collapsed = useRef<HTMLDivElement>(null);
const auto = mode === "auto";
const select = (option: string, close?: () => void) => {
onChange(option);
close?.();
};
// Popover takes over the trigger's ref, so the keys are read from the collapsed box.
const onKeyDown = (ev: React.KeyboardEvent) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) return;
const box = collapsed.current!;
const trigger = box.querySelector<HTMLElement>(`.${css.collapsed_trigger}`)!;
const items = [...box.querySelectorAll<HTMLElement>(`.${css.menu_item}`)];
if (!items.length) return;
const chosen = Math.max(0, options.indexOf(value));
if (trigger.getAttribute("aria-expanded") !== "true") {
if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
ev.preventDefault();
trigger.click();
// The box only shows after the re-render; focus waits for that frame.
requestAnimationFrame(() => items[chosen].focus());
return;
}
ev.preventDefault();
const at = items.indexOf(document.activeElement as HTMLElement);
const last = items.length - 1;
let next = chosen;
if (ev.key === "Home") {
next = 0;
} else if (ev.key === "End") {
next = last;
} else if (at >= 0 && ev.key === "ArrowDown") {
next = at === last ? 0 : at + 1;
} else if (at >= 0 && ev.key === "ArrowUp") {
next = at === 0 ? last : at - 1;
}
items[next].focus();
};
return (
<div
className={`${css.segmented} ${getSizeClasses(size)} ${auto ? css.auto : ""}`}
>
{(mode === "pills" || auto) && (
<div className={css.segmented_pills} role="group" aria-label={ariaLabel}>
{options.map((option) => {
const active = option === value;
return (
<button
key={option}
type="button"
className={`${css.pills_option} ${active ? css.active : ""}`}
aria-pressed={active}
onClick={() => select(option)}
>
{option}
</button>
);
})}
</div>
)}
{(mode === "dropdown" || auto) && (
<div ref={collapsed} className={css.segmented_collapsed} onKeyDown={onKeyDown}>
<Popover
trigger={
<button
type="button"
className={css.collapsed_trigger}
aria-label={`${ariaLabel}: ${value}`}
>
{value}
<CaretDownIcon className={css.trigger_caret} weight="bold" />
</button>
}
side="bottom"
align="end"
>
{(close) => (
<ul className={css.collapsed_menu}>
{options.map((option) => (
<li key={option}>
<button
type="button"
className={`${css.menu_item} ${option === value ? css.active : ""}`}
aria-pressed={option === value}
onClick={() => select(option, close)}
>
{option}
</button>
</li>
))}
</ul>
)}
</Popover>
</div>
)}
</div>
);
}
.segmented {
display: inline-block;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* Pill, trigger and menu item share one height, so collapsing never moves the row. */
&.xs {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
&.xs_base {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_base {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_base {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.xs_mobile {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_mobile {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_mobile {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_tablet {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_tablet {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_desktop {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_desktop {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_wide {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_wide {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
.segmented_pills {
display: inline-flex;
gap: var(--space-8);
}
.pills_option,
.collapsed_trigger {
border: var(--border-surface) solid var(--color-border-control);
}
.pills_option {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
/* The pill height owns the vertical rhythm; an inherited 1.5 would push the
text box past it. */
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
@media (hover: hover) {
.pills_option:not(.active):hover,
.collapsed_trigger:hover,
.menu_item:not(.active):hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
.pills_option:not(.active):active,
.collapsed_trigger:active,
.menu_item:not(.active):active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
/* Offset 0: a negative offset would hide the ring under the selected item's fill. */
.menu_item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
.collapsed_trigger {
display: inline-flex;
align-items: center;
gap: var(--space-8);
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
background: none;
border-radius: var(--radius-4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text);
transition: background 0.15s ease;
.trigger_caret {
width: 1em;
height: 1em;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
/* Popover injects aria-expanded, so the caret follows without state here. */
&[aria-expanded="true"] .trigger_caret {
transform: rotate(180deg);
}
}
.collapsed_menu {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.menu_item {
display: flex;
align-items: center;
width: 100%;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
color 0.15s ease;
}
/* After every part, so it wins over their heights at the same weight. */
@media (pointer: coarse) {
.pills_option,
.collapsed_trigger,
.menu_item {
height: var(--segmented-pill-touch);
padding: 0 calc(var(--segmented-pill-touch) * 0.6);
}
}
/* Last on purpose: same weight as .pills_option/.menu_item, so source order decides. */
.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
}
/* Container rules last so they win; with no container the viewport decides alone. */
&.auto {
.segmented_pills {
display: none;
@media (min-width: 480px) {
display: inline-flex;
}
@container (max-width: 479px) {
display: none;
}
}
.segmented_collapsed {
@media (min-width: 480px) {
display: none;
}
@container (max-width: 479px) {
display: block;
}
}
}
}
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { PhCaretDown } from "@phosphor-icons/vue";
import Popover from "@/components/library/vue/Popover/Popover.vue";
type Size = "xs" | "sm" | "md";
// Three values, not two booleans: "forced dropdown that also collapses" must not be expressible.
type Mode = "pills" | "dropdown" | "auto";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SegmentedProps {
options: string[];
size: Size | ResponsiveSize;
mode: Mode;
// Required: collapsed, the trigger shows only the value, nothing says of what.
ariaLabel: string;
}
const { options, size, mode, ariaLabel } = defineProps<SegmentedProps>();
const model = defineModel<string>({ required: true });
const collapsed = useTemplateRef<HTMLDivElement>("collapsed");
const auto = computed(() => mode === "auto");
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const select = (option: string, close?: () => void) => {
model.value = option;
close?.();
};
// Popover takes over the trigger's ref, so the keys are read from the collapsed box.
const onKeyDown = (ev: KeyboardEvent) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) return;
const box = collapsed.value!;
const trigger = box.querySelector<HTMLElement>(".collapsed_trigger")!;
const items = [...box.querySelectorAll<HTMLElement>(".menu_item")];
if (!items.length) return;
const chosen = Math.max(0, options.indexOf(model.value));
if (trigger.getAttribute("aria-expanded") !== "true") {
if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
ev.preventDefault();
trigger.click();
// The box only shows after the re-render; focus waits for that frame.
requestAnimationFrame(() => items[chosen].focus());
return;
}
ev.preventDefault();
const at = items.indexOf(document.activeElement as HTMLElement);
const last = items.length - 1;
let next = chosen;
if (ev.key === "Home") {
next = 0;
} else if (ev.key === "End") {
next = last;
} else if (at >= 0 && ev.key === "ArrowDown") {
next = at === last ? 0 : at + 1;
} else if (at >= 0 && ev.key === "ArrowUp") {
next = at === 0 ? last : at - 1;
}
items[next].focus();
};
</script>
<template>
<div class="segmented" :class="[sizeClasses, { auto }]">
<div
v-if="mode === 'pills' || auto"
class="segmented_pills"
role="group"
:aria-label="ariaLabel"
>
<button
v-for="option in options"
:key="option"
type="button"
class="pills_option"
:class="{ active: option === model }"
:aria-pressed="option === model"
@click="select(option)"
>
{{ option }}
</button>
</div>
<div
v-if="mode === 'dropdown' || auto"
ref="collapsed"
class="segmented_collapsed"
@keydown="onKeyDown"
>
<Popover side="bottom" align="end">
<template #trigger>
<button
type="button"
class="collapsed_trigger"
:aria-label="`${ariaLabel}: ${model}`"
>
{{ model }}
<PhCaretDown class="trigger_caret" weight="bold" />
</button>
</template>
<template #default="{ close }">
<ul class="collapsed_menu">
<li v-for="option in options" :key="option">
<button
type="button"
class="menu_item"
:class="{ active: option === model }"
:aria-pressed="option === model"
@click="select(option, close)"
>
{{ option }}
</button>
</li>
</ul>
</template>
</Popover>
</div>
</div>
</template>
<style scoped>
.segmented {
display: inline-block;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* Pill, trigger and menu item share one height, so collapsing never moves the row. */
&.xs {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
&.xs_base {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_base {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_base {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.xs_mobile {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_mobile {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_mobile {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_tablet {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_tablet {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_desktop {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_desktop {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_wide {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_wide {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
.segmented_pills {
display: inline-flex;
gap: var(--space-8);
}
.pills_option,
.collapsed_trigger {
border: var(--border-surface) solid var(--color-border-control);
}
.pills_option {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
/* The pill height owns the vertical rhythm; an inherited 1.5 would push the
text box past it. */
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
@media (hover: hover) {
.pills_option:not(.active):hover,
.collapsed_trigger:hover,
.menu_item:not(.active):hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
.pills_option:not(.active):active,
.collapsed_trigger:active,
.menu_item:not(.active):active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
/* Offset 0: a negative offset would hide the ring under the selected item's fill. */
.menu_item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
.collapsed_trigger {
display: inline-flex;
align-items: center;
gap: var(--space-8);
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
background: none;
border-radius: var(--radius-4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text);
transition: background 0.15s ease;
.trigger_caret {
width: 1em;
height: 1em;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
/* Popover injects aria-expanded, so the caret follows without state here. */
&[aria-expanded="true"] .trigger_caret {
transform: rotate(180deg);
}
}
.collapsed_menu {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.menu_item {
display: flex;
align-items: center;
width: 100%;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
color 0.15s ease;
}
/* After every part, so it wins over their heights at the same weight. */
@media (pointer: coarse) {
.pills_option,
.collapsed_trigger,
.menu_item {
height: var(--segmented-pill-touch);
padding: 0 calc(var(--segmented-pill-touch) * 0.6);
}
}
/* Last on purpose: same weight as .pills_option/.menu_item, so source order decides. */
.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
}
/* Container rules last so they win; with no container the viewport decides alone. */
&.auto {
.segmented_pills {
display: none;
@media (min-width: 480px) {
display: inline-flex;
}
@container (max-width: 479px) {
display: none;
}
}
.segmented_collapsed {
@media (min-width: 480px) {
display: none;
}
@container (max-width: 479px) {
display: block;
}
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| options | string[] | required | Control options; the text doubles as the value. |
| size | "xs" | "sm" | "md" | ResponsiveSize | required | Each option is 24, 28 or 32 tall, and one step taller on touch screens. The text stays at 14 in all three. Fixed or responsive. |
| mode | "pills" | "dropdown" | "auto" | required | Shape of the control. With auto it starts as pills and collapses to a dropdown below a 480px viewport (the mobile breakpoint), or when its container drops below 480px (only if the consumer declares container-type). |
| aria-label | string | required | Name of the control for screen readers. Not displayed, but once collapsed the trigger only shows the chosen option and nothing says what it belongs to. |
| value | string | required | Selected option (controlled). |
| onChange | (value: string) => void | required | Callback with the chosen option. |