Components

Segmented control

Mutually exclusive choice with a sliding active pill. Pill track on subtle bg, white pill on active. Three sizes, N segments.

Preview#

Installation#

Same package as the rest of Foren.

bash
npm install @foren/ui
tsx
import { ForenSegmentedControl } from "@foren/ui";

Usage#

Controlled or uncontrolled. Active pill slides between segments.

tsx
// Uncontrolled
<ForenSegmentedControl
  defaultValue="day"
  options={[
    { value: "day",   label: "Day" },
    { value: "week",  label: "Week" },
    { value: "month", label: "Month" },
  ]}
/>

// Controlled
const [view, setView] = useState<"day" | "week" | "month">("day");
<ForenSegmentedControl
  value={view}
  onChange={setView}
  options={[…]}
/>

Source#

Drop-in source if you'd rather skip the package.

components/ui/foren-segmented-control.tsx
"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const trackVariants = cva(
  "relative inline-flex items-stretch bg-[var(--color-foren-bg-subtle)] rounded-full",
  {
    variants: {
      size: {
        sm: "p-[3px] h-9",
        md: "p-1 h-11",
        lg: "p-1 h-12",
      },
    },
    defaultVariants: { size: "md" },
  }
);

const segmentVariants = cva(
  "relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full transition-colors duration-150 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-foren-bg-subtle)] focus-visible:ring-[var(--color-foren-border-focus)] disabled:cursor-not-allowed disabled:opacity-40",
  {
    variants: {
      size: {
        sm: "h-full px-4 text-[13px] leading-[18px]",
        md: "h-full px-5 text-[14px] leading-[21px]",
        lg: "h-full px-5 text-[14px] leading-[21px]",
      },
      stretch: {
        true: "flex-1 min-w-0",
        false: "flex-none",
      },
      active: {
        true: "font-semibold tracking-[0.0125em] text-[var(--color-foren-text-primary)]",
        false: "font-medium tracking-[0.01em] text-[var(--color-foren-text-tertiary)] hover:text-[var(--color-foren-text-secondary)]",
      },
    },
    defaultVariants: { size: "md", active: false, stretch: false },
  }
);

export type ForenSegmentOption<V extends string = string> = {
  value: V;
  label: React.ReactNode;
  icon?: React.ReactNode;
  disabled?: boolean;
};

export interface ForenSegmentedControlProps<V extends string = string>
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">,
    VariantProps<typeof trackVariants> {
  options: ForenSegmentOption<V>[];
  value?: V;
  defaultValue?: V;
  onChange?: (next: V) => void;
  /** Full-width track. Defaults to false (intrinsic width). */
  fullWidth?: boolean;
  /** aria-label when no surrounding label. */
  ariaLabel?: string;
}

export function ForenSegmentedControl<V extends string = string>({
  options,
  value: controlled,
  defaultValue,
  onChange,
  size = "md",
  fullWidth = false,
  className,
  ariaLabel = "Segmented control",
  ...rest
}: ForenSegmentedControlProps<V>) {
  const isControlled = controlled !== undefined;
  const [internal, setInternal] = React.useState<V>(
    (defaultValue ?? options[0]?.value) as V
  );
  const value = isControlled ? controlled! : internal;
  const trackRef = React.useRef<HTMLDivElement>(null);
  const [pill, setPill] = React.useState<{ x: number; w: number; h: number } | null>(null);

  // Position the active pill via JS for smooth slide between segments.
  React.useLayoutEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const update = () => {
      const active = track.querySelector<HTMLButtonElement>(`[data-value="${value}"]`);
      if (!active) {
        setPill(null);
        return;
      }
      const trackRect = track.getBoundingClientRect();
      const r = active.getBoundingClientRect();
      setPill({ x: r.left - trackRect.left, w: r.width, h: r.height });
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(track);
    return () => ro.disconnect();
  }, [value, options.length, size]);

  function select(v: V) {
    if (!isControlled) setInternal(v);
    onChange?.(v);
  }

  return (
    <div
      ref={trackRef}
      role="tablist"
      aria-label={ariaLabel}
      className={cn(trackVariants({ size }), fullWidth ? "w-full" : "w-auto", className)}
      {...rest}
    >
      {pill && (
        <span
          aria-hidden
          className="absolute rounded-full bg-[var(--color-foren-bg-elevated)] shadow-[var(--shadow-foren-sm)] transition-[left,width] duration-300 ease-[cubic-bezier(0.34,1.2,0.64,1)]"
          style={{ left: pill.x, width: pill.w, height: pill.h, top: "50%", transform: "translateY(-50%)" }}
        />
      )}
      {options.map((opt) => {
        const active = opt.value === value;
        return (
          <button
            key={opt.value}
            type="button"
            role="tab"
            aria-selected={active}
            data-value={opt.value}
            disabled={opt.disabled}
            onClick={() => !opt.disabled && select(opt.value)}
            className={segmentVariants({ size, active, stretch: fullWidth })}
          >
            {opt.icon}
            <span className="truncate">{opt.label}</span>
          </button>
        );
      })}
    </div>
  );
}

Sizes#

Three sizes — sm 36, md 44 (default), lg 48.

Segments count#

Two, three, four — any count works.

Full width#

Stretch to fill the container.

Interactive#

Click to switch — pill slides between segments.

value: all

API#

PropTypeDefault
optionsForenSegmentOption[]
valuestring (controlled)
defaultValuestring
onChange(next) => void
size"sm" | "md" | "lg"
fullWidthboolean
ariaLabelstring