Segmented toggle
Toggle for two to four options.
Code
Copy into src/components/ph/ .
segmented-toggle.css
.ph-toggle__chip {
width: calc((100% - 4px) / var(--count));
transform: translateX(calc(100% * var(--index)));
transition: transform 200ms var(--ph-ease);
}
@media (prefers-reduced-motion: reduce) {
.ph-toggle__chip {
transition: none;
}
}
SegmentedToggle.astro
---
import { icons, type IconName } from "./icons";
import "./segmented-toggle.css";
type Option = {
value: string;
label: string;
icon?: IconName;
};
type Props = {
name: string;
options: Option[];
value?: string;
label?: string;
class?: string;
};
const { name, options, value = options[0]?.value, label, class: className } = Astro.props;
const index = Math.max(0, options.findIndex((o) => o.value === value));
---
<div
role="group"
aria-label={label ?? name}
data-ph-toggle={name}
class:list={["relative isolate grid w-fit shrink-0 auto-cols-fr grid-flow-col bg-foreground/[0.08] p-0.5", className]}
style={`--count: ${options.length}; --index: ${index}`}
>
<span aria-hidden="true" class="ph-toggle__chip pointer-events-none absolute inset-y-0.5 left-0.5 -z-10 bg-background"></span>
{
options.map((o) => (
<button
type="button"
data-value={o.value}
aria-pressed={o.value === value ? "true": "false"}
aria-label={o.icon ? o.label : undefined}
class="flex min-w-8 cursor-pointer items-center justify-center px-2 py-1.5 transition-colors duration-200 ease-quint"
>
{o.icon ? <svg viewBox="0 0 24 24" fill="currentColor" class="size-3.5" aria-hidden="true" set:html={icons[o.icon]} /> : o.label}
</button>
))
}
</div>
<script>
for (const group of document.querySelectorAll<HTMLElement>("[data-ph-toggle]")) {
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button[data-value]")];
for (const [i, b] of buttons.entries()) {
b.addEventListener("click", () => {
if (b.getAttribute("aria-pressed") === "true") return;
for (const other of buttons) other.setAttribute("aria-pressed", String(other === b));
group.style.setProperty("--index", String(i));
group.dispatchEvent(
new CustomEvent("ph:change", { bubbles: true, detail: { name: group.dataset.phToggle, value: b.dataset.value } }),
);
});
}
}
</script>
Copy into components/ph/ (or src/components/ph/).
segmented-toggle.css
.ph-toggle__chip {
width: calc((100% - 4px) / var(--count));
transform: translateX(calc(100% * var(--index)));
transition: transform 200ms var(--ph-ease);
}
@media (prefers-reduced-motion: reduce) {
.ph-toggle__chip {
transition: none;
}
}
segmented-toggle.tsx
"use client";
import { useState, type CSSProperties } from "react";
import { cn } from "./cn";
import { icons, type IconName } from "./icons";
import "./segmented-toggle.css";
type Option = {
value: string;
label: string;
icon?: IconName;
};
type SegmentedToggleProps = {
options: Option[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
label?: string;
className?: string;
};
export function SegmentedToggle({ options, value, defaultValue, onValueChange, label, className }: SegmentedToggleProps) {
const [inner, setInner] = useState(defaultValue ?? options[0]?.value);
const current = value ?? inner;
const index = Math.max(0, options.findIndex((o) => o.value === current));
return (
<div
role="group"
aria-label={label}
className={cn("relative isolate grid w-fit shrink-0 auto-cols-fr grid-flow-col bg-foreground/[0.08] p-0.5", className)}
style={{ "--count": options.length, "--index": index } as CSSProperties}
>
<span aria-hidden="true" className="ph-toggle__chip pointer-events-none absolute inset-y-0.5 left-0.5 -z-10 bg-background"/>
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={o.value === current}
aria-label={o.icon ? o.label : undefined}
onClick={() => {
if (o.value === current) return;
setInner(o.value);
onValueChange?.(o.value);
}}
className="flex min-w-8 cursor-pointer items-center justify-center px-2 py-1.5 transition-colors duration-200 ease-quint"
>
{o.icon ? (
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3.5" aria-hidden="true" dangerouslySetInnerHTML={{ __html: icons[o.icon] }} />
) : (
o.label
)}
</button>
))}
</div>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");