jengacore.shadcn registry · Tailwind v4 · Radix and Base UI
Components
jengacore components · @jengacore/segmented

Segmented

Compact single choice that acts at once: USD / SLSH, All / Mine / Unassigned. A radio group with arrow keys.

Install

terminal
npx shadcn@latest add @jengacore/segmented

Without the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/segmented.json

Registry dependencies

Source

components/jc/segmented.tsx
"use client"

import * as React from "react"

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

export type SegmentOption<T extends string> = { value: T; label: React.ReactNode; count?: number }

/**
 * Compact single choice for filters and switches that act at once: USD / SLSH, All / Mine / Unassigned,
 * Cash / Mobile money / Transfer. A radio group, so arrow keys move between options.
 * Use Tabs to switch views of the same object, the sidebar to switch between objects.
 */
export function Segmented<T extends string>({
  value,
  onValueChange,
  options,
  label,
  size = "default",
  className,
}: {
  value: T
  onValueChange: (value: T) => void
  options: SegmentOption<T>[]
  /** Accessible name of the group: "Currency", "Queue filter". */
  label: string
  size?: "default" | "lg"
  className?: string
}) {
  const refs = React.useRef<(HTMLButtonElement | null)[]>([])
  const move = (from: number, delta: number) => {
    const next = (from + delta + options.length) % options.length
    const option = options[next]
    if (!option) return
    onValueChange(option.value)
    refs.current[next]?.focus()
  }
  return (
    <div
      role="radiogroup"
      aria-label={label}
      data-slot="segmented"
      className={cn("inline-flex gap-0.5 rounded-[7px] bg-muted p-[3px] contrast:outline contrast:outline-input", size === "lg" ? "h-9" : "h-[30px]", className)}
    >
      {options.map((o, i) => {
        const on = o.value === value
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el
            }}
            type="button"
            role="radio"
            aria-checked={on}
            tabIndex={on ? 0 : -1}
            onClick={() => onValueChange(o.value)}
            onKeyDown={(e) => {
              if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                e.preventDefault()
                move(i, 1)
              } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                e.preventDefault()
                move(i, -1)
              }
            }}
            className={cn(
              "inline-flex items-center gap-1.5 rounded-[5px] px-3 text-[13px] whitespace-nowrap text-muted-foreground transition-colors duration-fast ease-jc hover:text-foreground",
              "focus-ring [--focus-offset:1px]",
              on && "pressed-state"
            )}
          >
            {o.label}
            {o.count !== undefined ? <span className="font-mono text-[11px] text-muted-foreground">{o.count}</span> : null}
          </button>
        )
      })}
    </div>
  )
}