docs / sula / sula-segmented

SulaSegmented

a segmented control whose active indicator is a liquid droplet. pick a new segment and a drop pinches off the old pill, travels on a thinning neck, and merges into the new one.

intensity · assertsrsc · client

provenance

authored in usva

layer

sula

intensity

asserts · one per region. it is the focal point

composition

brand surfaces: a theme switcher on a portfolio, a view toggle on a heroit mirrors SegmentedControl exactly, keyboard and semantics includeddense or task-bound UI takes the plain SegmentedControl. there the effect is noiseone sula member per region

a11y

role="radiogroup", each segment a radio with aria-checked and roving tabindex · the canvas and icons are aria-hiddenjest-axe

dependencies

motion · ogl · sula-core + sula-motion from the same package
live demo · try it out
customize
sizesegment height and padding
fluidoff renders the plain sliding pill
baredrops the track fill and border
shine0 matte glass, 1 full neon rim
0.6
usagecopy
import { SulaSegmented } from "usva/sula/sula-segmented";

<SulaSegmented
  shine={0.6}
  items={items}
  value={value}
  onValueChange={setValue}
/>

props

proptypedefaultnotes
itemsSulaSegmentedItem[]each entry is { value, label, icon? }. the segments are real buttons, measured at rest.
valuestringcontrolled: the selected value.
defaultValuestringuncontrolled: the value selected on mount. falls back to the first item.
onValueChange(value: string) => voidfires when a segment is picked, by click or by arrow key.
size"sm" | "md""md"segment height and padding.
fluidbooleantruefalse renders the plain sliding pill and mounts no canvas. also the automatic path under reduced motion or missing WebGL2, and what the server always renders.
barebooleanfalsedrops the track fill and border, leaving the indicator on a bare surface.
backdropstringthe colour the glass tints against. defaults to the bg token.
tintstringthe glass itself. defaults to the surface-2 token.
accentColorstringrim light and glow. defaults to the accent token.
shinenumber0 is flat matte glass, 1 is the full neon rim. derived from the backdrop when unset.

get it

npx shadcn add https://usva.build/r/sula-segmented.jsoncopy
source · components/ui/segmented-geometry.tsexactly what this command copiescopy
import type { Blob, Neck } from "./geometry";
import {
  c1Settle,
  clamp01,
  mix,
  smoother,
  smoothstep,
} from "./curves";

/** The tether back to the source starts melting once the pill has clearly left. */
const NECK_MELT_START = 0.08;
/** ...and is fully gone by here, well before landing, via strength rather than
 * by thinning: a thinning neck ends as a hard line that snaps out. */
const NECK_MELT_END = 0.55;
/** Mid-flight the pill squashes a little, like liquid stretched by its travel. */
const FLIGHT_SQUASH = 0.16;
const FLIGHT_STRETCH = 0.09;

/** A measured segment rect, relative to the stage box, as a fully rounded pill. */
export function pillFromRect(rect: {
  left: number;
  top: number;
  width: number;
  height: number;
}): Blob {
  const hw = rect.width / 2;
  const hh = rect.height / 2;
  return {
    cx: rect.left + hw,
    cy: rect.top + hh,
    hw,
    hh,
    r: Math.min(hw, hh),
  };
}

/**
 * The indicator mid-transition: one pill that leaves the source, travels in a
 * straight line to the target and merges into it, its size eased continuously
 * from source to target the whole way. A brief fat neck ties it back to the
 * source and melts via strength while the pill is still near, so nothing ever
 * thins into a thread or snaps. `t` runs 0 (source) to 1 (target) and may pass 1
 * for the settle overshoot; overshoot moves position, never size. At rest a
 * caller draws just `[targetPill]`; this covers only the live transition.
 */
export function indicatorPhase(
  source: Blob,
  target: Blob,
  t: number,
): { blobs: Blob[]; neck: Neck | null } {
  const p = clamp01(t);
  const travel = c1Settle(t, 0);
  const sizeT = smoother(p);
  const flight = Math.sin(Math.PI * p);

  const cx = mix(source.cx, target.cx, travel);
  const cy = mix(source.cy, target.cy, travel);
  const hw = mix(source.hw, target.hw, sizeT) * (1 + FLIGHT_STRETCH * flight);
  const hh = mix(source.hh, target.hh, sizeT) * (1 - FLIGHT_SQUASH * flight);
  const pill: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };

  const strength = 1 - smoothstep(NECK_MELT_START, NECK_MELT_END, p);
  if (strength <= 0.001) return { blobs: [pill], neck: null };

  const dir = Math.sign(target.cx - source.cx) || 1;
  const neck: Neck = {
    ax: source.cx,
    ay: source.cy,
    bx: cx - dir * hw,
    by: cy,
    r: Math.min(source.hh, hh) * 0.55,
    strength,
  };
  return { blobs: [pill], neck };
}
source · components/ui/sula-segmented.tsxexactly what this command copiescopy
"use client";
import { animate, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  createField,
  type FieldColors,
  resolveColor,
  shineForBackdrop,
} from "./field";
import { type Blob, type Neck, packUniforms } from "./geometry";
import { createPauseGate } from "./pause";
import { useContextRecovery } from "./recovery";
import { useFieldRetune } from "./retune";
import { createEnergyTracker } from "./energy";
import { switchSpring } from "./springs";
import { indicatorPhase, pillFromRect } from "./segmented-geometry";

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

export interface SulaSegmentedProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "onChange" | "defaultValue"
  > {
  items: SulaSegmentedItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  size?: "sm" | "md";
  /** false or reduced-motion renders the plain sliding pill and mounts no canvas. */
  fluid?: boolean;
  /** Drops the track fill and border, leaving the indicator on a bare surface. */
  bare?: boolean;
  accentColor?: string;
  backdrop?: string;
  tint?: string;
  shine?: number;
}

const sizeClasses: Record<NonNullable<SulaSegmentedProps["size"]>, string> = {
  sm: "h-8 px-3",
  md: "h-9 px-4",
};

/** Room past the track so the pill's rounded caps are not clipped by the stage. */
const SLACK_X = 20;
const SLACK_Y = 10;
const MAX_DPR = 2;
/** Merge radius at rest: firm glass. */
const REST_K = 14;
/** Merge radius while a switch is live: gooey. */
const K_ACTIVE = 24;
/** Peak surface undulation in px, alive only while the droplet moves. */
const WOBBLE = 0.6;

function readColors(
  node: HTMLElement,
  overrides: {
    backdrop?: string;
    tint?: string;
    accent?: string;
    shine?: number;
  },
): FieldColors {
  const styles = getComputedStyle(node);
  const token = (name: string) => styles.getPropertyValue(name).trim();
  const backdrop = resolveColor(overrides.backdrop ?? token("--usva-bg"));
  const tintToken =
    overrides.tint ?? token("--usva-surface-2") ?? token("--usva-surface");
  return {
    backdrop,
    tint: resolveColor(tintToken || token("--usva-surface")),
    accent: resolveColor(overrides.accent ?? token("--usva-accent")),
    shine: overrides.shine ?? shineForBackdrop(backdrop),
  };
}

export const SulaSegmented = React.forwardRef<
  HTMLDivElement,
  SulaSegmentedProps
>(
  (
    {
      items,
      value,
      defaultValue,
      onValueChange,
      size = "md",
      fluid = true,
      bare = false,
      accentColor,
      backdrop,
      tint,
      shine,
      className,
      ...props
    },
    forwardedRef,
  ) => {
    const reduced = useReducedMotion();
    const rootRef = React.useRef<HTMLDivElement | null>(null);
    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
    const stageRef = React.useRef<HTMLDivElement | null>(null);
    const segmentRefs = React.useRef<Array<HTMLButtonElement | null>>([]);

    const { failed, generation, onContextLost, onContextReady } =
      useContextRecovery(canvasRef);
    const overrides = React.useMemo(
      () => ({ backdrop, tint, accent: accentColor, shine }),
      [backdrop, tint, accentColor, shine],
    );
    const overridesRef = React.useRef(overrides);
    overridesRef.current = overrides;
    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);
    const wakeRef = React.useRef<(() => void) | null>(null);
    const [mounted, setMounted] = React.useState(false);
    React.useEffect(() => setMounted(true), []);

    const isFluid = fluid && !reduced && !failed && mounted;

    const isControlled = value !== undefined;
    const [uncontrolled, setUncontrolled] = React.useState(
      () => defaultValue ?? items[0]?.value,
    );
    const current = isControlled ? value : uncontrolled;

    const activeIndex = Math.max(
      0,
      items.findIndex((item) => item.value === current),
    );
    const activeIndexRef = React.useRef(activeIndex);
    activeIndexRef.current = activeIndex;

    const switchRef = React.useRef<(previous: number) => void>(() => {});

    // The plain sliding pill, used whenever the field is not running.
    const [indicator, setIndicator] = React.useState({
      left: 0,
      width: 0,
      ready: false,
    });
    const measurePlain = React.useCallback(() => {
      const el = segmentRefs.current[activeIndex];
      if (!el) return;
      setIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
    }, [activeIndex]);

    React.useLayoutEffect(() => {
      measurePlain();
    }, [measurePlain]);

    React.useEffect(() => {
      if (typeof ResizeObserver === "undefined") return;
      const observer = new ResizeObserver(() => measurePlain());
      for (const el of segmentRefs.current) {
        if (el) observer.observe(el);
      }
      return () => observer.disconnect();
    }, [measurePlain]);

    // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context
    React.useEffect(() => {
      if (!isFluid) return;
      const canvas = canvasRef.current;
      const stage = stageRef.current;
      const root = rootRef.current;
      if (!canvas || !stage || !root) return;

      const field = createField({
        canvas,
        colors: readColors(root, overridesRef.current),
        onContextLost,
      });
      if (!field) {
        onContextLost();
        return;
      }
      fieldRef.current = field;
      onContextReady();

      let pills: Blob[] = [];
      let canvasH = 0;
      let dpr = 1;
      let raf = 0;
      let running = 0;
      const energy = createEnergyTracker();
      const start = performance.now();

      const swT = { value: 1 };
      let pSwitch = 1;
      let transitioning = false;
      let source: Blob | null = null;
      let target: Blob | null = null;
      let lastIndicator: Blob | null = null;

      let lastW = 0;
      let lastH = 0;
      const measure = () => {
        const box = stage.getBoundingClientRect();
        const width = Math.ceil(box.width);
        const height = Math.ceil(box.height);
        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
        if (width !== lastW || height !== lastH || nextDpr !== dpr) {
          canvas.style.width = `${width}px`;
          canvas.style.height = `${height}px`;
          field.resize(width, height, nextDpr);
          lastW = width;
          lastH = height;
        }
        canvasH = height;
        dpr = nextDpr;
        pills = segmentRefs.current.map((el) => {
          const rect = el?.getBoundingClientRect();
          if (!rect)
            return pillFromRect({ left: 0, top: 0, width: 0, height: 0 });
          return pillFromRect({
            left: rect.left - box.left,
            top: rect.top - box.top,
            width: rect.width,
            height: rect.height,
          });
        });
      };

      const paint = (blobs: Blob[], necks: Neck[], liveK: number) => {
        field.draw({
          packed: packUniforms({ blobs, necks, k: liveK }, dpr, canvasH),
          k: liveK * dpr,
          time: (performance.now() - start) / 1000,
          wobble: WOBBLE * energy.value,
          alpha: 1,
        });
      };

      const liveK = () => REST_K + (K_ACTIVE - REST_K) * energy.value;

      const tick = () => {
        if (transitioning && source && target) {
          const v = swT.value - pSwitch;
          pSwitch = swT.value;
          energy.bump(Math.abs(v));
          const k = liveK();
          const { blobs, neck } = indicatorPhase(source, target, swT.value);
          lastIndicator = blobs[0] ?? null;
          const necks: Neck[] = neck ? [neck] : [];
          paint(blobs, necks, k);
        } else {
          energy.bump(0);
          const active = pills[activeIndexRef.current];
          if (active) paint([active], [], liveK());
        }

        if (running === 0 && energy.parked()) {
          transitioning = false;
          return;
        }
        raf = requestAnimationFrame(tick);
      };
      const wake = () => {
        if (!gate.awake()) return;
        cancelAnimationFrame(raf);
        raf = requestAnimationFrame(tick);
      };
      wakeRef.current = wake;
      const gate = createPauseGate({
        target: root,
        onPause: () => cancelAnimationFrame(raf),
        onResume: () => wake(),
      });

      const controls = new Set<ReturnType<typeof animate>>();
      const run = (
        subject: { value: number },
        to: [number, number],
        opts: typeof switchSpring,
      ) => {
        running += 1;
        const control = animate(subject, { value: to }, opts);
        controls.add(control);
        const finish = () => {
          running = Math.max(0, running - 1);
          controls.delete(control);
        };
        void control.finished.then(finish, finish);
        wake();
        return control;
      };

      let disposed = false;
      let swControl: ReturnType<typeof animate> | null = null;

      const doSwitch = (previous: number) => {
        /* Restarting an in-flight switch from a segment pill would yank the
         * indicator back and read as a stall; continue from where it is. */
        const inFlight = transitioning ? lastIndicator : null;
        measure();
        const src = inFlight ?? pills[previous];
        const tgt = pills[activeIndexRef.current];
        if (!src || !tgt) return;
        source = src;
        target = tgt;
        transitioning = true;
        swT.value = 0;
        pSwitch = 0;
        swControl?.stop();
        /* Motion caches one visual element per animated subject and reads the next
         * "from" out of it, so a bare swT.value reset is invisible to it. The
         * explicit [0, 1] keyframes are what make the second and every later switch
         * animate instead of snapping. */
        swControl = run(swT, [0, 1], switchSpring);
        void swControl.finished.then(
          () => {
            if (disposed) return;
            transitioning = false;
            wake();
          },
          () => undefined,
        );
      };
      switchRef.current = doSwitch;

      measure();
      wake();

      const remeasure = () => {
        measure();
        wake();
      };
      const observer =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(remeasure);
      observer?.observe(root);
      window.addEventListener("resize", remeasure);
      void document.fonts?.ready.then(remeasure).catch(() => undefined);

      const themeObserver =
        typeof MutationObserver === "undefined"
          ? null
          : new MutationObserver(() => {
              field.setColors(readColors(root, overridesRef.current));
              wake();
            });
      themeObserver?.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["data-theme", "class"],
      });

      return () => {
        disposed = true;
        for (const control of controls) control.stop();
        switchRef.current = () => {};
        window.removeEventListener("resize", remeasure);
        observer?.disconnect();
        themeObserver?.disconnect();
        gate.dispose();
        cancelAnimationFrame(raf);
        field.dispose();
        fieldRef.current = null;
        wakeRef.current = null;
        canvas.style.width = "";
        canvas.style.height = "";
      };
    }, [isFluid, generation]);

    useFieldRetune(
      fieldRef,
      () => (rootRef.current ? readColors(rootRef.current, overrides) : null),
      overrides,
      wakeRef,
    );

    const previousIndex = React.useRef(activeIndex);
    React.useLayoutEffect(() => {
      const previous = previousIndex.current;
      previousIndex.current = activeIndex;
      if (isFluid && previous !== activeIndex) {
        switchRef.current(previous);
      }
    }, [activeIndex, isFluid]);

    const select = React.useCallback(
      (next: string) => {
        if (!isControlled) setUncontrolled(next);
        onValueChange?.(next);
      },
      [isControlled, onValueChange],
    );

    const focusIndex = (index: number) => {
      const clamped = (index + items.length) % items.length;
      const targetItem = items[clamped];
      if (!targetItem) return;
      segmentRefs.current[clamped]?.focus();
      select(targetItem.value);
    };

    const onKeyDown = (event: React.KeyboardEvent, index: number) => {
      switch (event.key) {
        case "ArrowRight":
        case "ArrowDown":
          event.preventDefault();
          focusIndex(index + 1);
          break;
        case "ArrowLeft":
        case "ArrowUp":
          event.preventDefault();
          focusIndex(index - 1);
          break;
        case "Home":
          event.preventDefault();
          focusIndex(0);
          break;
        case "End":
          event.preventDefault();
          focusIndex(items.length - 1);
          break;
        default:
          break;
      }
    };

    return (
      <div
        ref={(node) => {
          rootRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        role="radiogroup"
        data-fluid={isFluid ? "on" : "off"}
        className={cn(
          "relative inline-flex items-center rounded-full p-1",
          !bare && "border border-border bg-surface",
          className,
        )}
        {...props}
      >
        {isFluid ? (
          <div
            ref={stageRef}
            aria-hidden="true"
            className="pointer-events-none absolute overflow-hidden"
            style={{
              top: -SLACK_Y,
              bottom: -SLACK_Y,
              left: -SLACK_X,
              right: -SLACK_X,
            }}
          >
            <canvas ref={canvasRef} />
          </div>
        ) : (
          <span
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute top-1 bottom-1 left-0 rounded-full bg-surface-2 shadow-raised",
              "[filter:drop-shadow(var(--usva-glow-accent))]",
              "transition-layout duration-slow ease-spring motion-reduce:transition-none",
              !indicator.ready && "opacity-0",
            )}
            style={{
              width: indicator.width,
              transform: `translateX(${indicator.left}px)`,
            }}
          />
        )}
        {items.map((item, index) => {
          const checked = item.value === current;
          return (
            // biome-ignore lint/a11y/useSemanticElements: segmented control needs a button with a roving tabindex and a custom indicator; a native radio input can't render this pattern
            <button
              key={item.value}
              type="button"
              role="radio"
              aria-checked={checked}
              tabIndex={checked ? 0 : -1}
              ref={(node) => {
                segmentRefs.current[index] = node;
              }}
              onClick={() => select(item.value)}
              onKeyDown={(event) => onKeyDown(event, index)}
              className={cn(
                "relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full text-sm whitespace-nowrap outline-none",
                sizeClasses[size],
                "text-muted transition-tint duration-fast ease-soft",
                "hover:text-ink aria-checked:text-ink",
                "focus-visible:ring-focus",
              )}
            >
              {item.icon ? (
                <span className="inline-flex shrink-0" aria-hidden="true">
                  {item.icon}
                </span>
              ) : null}
              {item.label}
            </button>
          );
        })}
      </div>
    );
  },
);
SulaSegmented.displayName = "SulaSegmented";