# Astryx Button Group

> Segmented radio group with a spring-sliding glowing thumb and arrow-key navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/astryx-button-group
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/astryx-button-group.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/astryx-button-group-demo.tsx"
import { Moon, Monitor, Sun } from "lucide-react"

import { AstryxButtonGroup } from "@/components/astryx-button-group"

export default function AstryxButtonGroupDemo() {
  return (
    <div className="flex flex-col items-center gap-6 p-8">
      <AstryxButtonGroup
        aria-label="Billing period"
        options={[
          { value: "monthly", label: "Monthly" },
          { value: "yearly", label: "Yearly" },
        ]}
      />
      <AstryxButtonGroup
        aria-label="Theme"
        defaultValue="dark"
        options={[
          { value: "light", label: "Light", icon: <Sun /> },
          { value: "dark", label: "Dark", icon: <Moon /> },
          { value: "system", label: "System", icon: <Monitor /> },
        ]}
      />
    </div>
  )
}
```

## Source

```tsx title="components/astryx-button-group.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface AstryxOption {
  value: string
  label: string
  icon?: React.ReactNode
}

export interface AstryxButtonGroupProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  options: AstryxOption[]
  defaultValue?: string
  onValueChange?: (value: string) => void
}

export function AstryxButtonGroup({
  options,
  defaultValue,
  onValueChange,
  className,
  ...props
}: AstryxButtonGroupProps) {
  const [value, setValue] = React.useState(defaultValue ?? options[0]?.value)
  const refs = React.useRef<Array<HTMLButtonElement | null>>([])
  const activeIndex = Math.max(
    0,
    options.findIndex((option) => option.value === value)
  )

  function select(index: number) {
    const option = options[index]
    if (!option) return
    setValue(option.value)
    onValueChange?.(option.value)
    refs.current[index]?.focus()
  }

  function onKeyDown(event: React.KeyboardEvent) {
    const last = options.length - 1
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault()
      select(activeIndex === last ? 0 : activeIndex + 1)
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault()
      select(activeIndex === 0 ? last : activeIndex - 1)
    }
  }

  return (
    <div
      role="radiogroup"
      className={cn(
        "relative inline-grid rounded-full border border-border bg-muted/60 p-1 shadow-inner",
        className
      )}
      style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
      onKeyDown={onKeyDown}
      {...props}
    >
      <span
        aria-hidden
        className="pointer-events-none absolute top-1 bottom-1 left-1 rounded-full bg-primary shadow-[0_0_16px_-2px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.3)] transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)]"
        style={{
          width: `calc((100% - 0.5rem) / ${options.length})`,
          transform: `translateX(${activeIndex * 100}%)`,
        }}
      />
      {options.map((option, index) => {
        const selected = index === activeIndex
        return (
          <button
            key={option.value}
            ref={(node) => {
              refs.current[index] = node
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            className={cn(
              "relative z-10 inline-flex h-9 items-center justify-center gap-2 rounded-full px-5 text-sm font-medium transition-colors duration-200",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
              selected ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
            )}
            onClick={() => select(index)}
          >
            {option.icon && <span className="[&_svg]:size-4">{option.icon}</span>}
            {option.label}
          </button>
        )
      })}
    </div>
  )
}
```
