# Jelly Radio

A radio group of chips where the chosen one swells like jelly and barges its neighbours outward before everything wobbles back into line. Arrow keys move the choice.

**Category:** Inputs  
**Exports:** `JellyRadio`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/jelly-radio.json
```
The file lands at `components/ui/jelly-radio.tsx`; import from `@/components/ui/jelly-radio`.

## API — Jelly Radio
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | `string[]` | — |  |
| `value / defaultValue` | `string` | `options[0]` |  |
| `onValueChange` | `(v: string) => void` | — |  |
| `label` | `string` | — |  |

## Example — Jelly Radio

```tsx
import { JellyRadio } from "@/components/ui/jelly-radio";

export default function JellyRadioDemo() {
  return <JellyRadio label="Plan" options={["Daily", "Weekly", "Monthly", "Yearly"]} defaultValue="Weekly" />;
}
```

## Source — components/ui/jelly-radio.tsx

```tsx
"use client";

import * as React from "react";
import { animate, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type JellyRadioProps = {
  options: string[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (v: string) => void;
  label?: string;
  className?: string;
};

/** A radio group of chips where the chosen one swells like jelly and barges its neighbours outward, then everything wobbles back into line. */
export function JellyRadio({ options, value: valueProp, defaultValue, onValueChange, label = "Options", className }: JellyRadioProps) {
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue ?? options[0]);
  const value = valueProp ?? inner;
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const pick = (o: string) => {
    setInner(o);
    onValueChange?.(o);
    if (reduce) return;
    const n = options.indexOf(o);
    const el = refs.current[n];
    if (el) animate(el, { scaleX: [1, 1.18, 0.94, 1.04, 1], scaleY: [1, 0.86, 1.08, 0.97, 1] }, { duration: 0.55, ease: "easeOut" });
    // Neighbours get barged outward, less the further away they sit.
    refs.current.forEach((b, i) => {
      const d = i - n;
      if (!b || !d || Math.abs(d) > 2) return;
      animate(b, { x: [0, Math.sign(d) * (14 - Math.abs(d) * 4), 0] }, { duration: 0.5, ease: "easeOut" });
    });
  };

  return (
    <div role="radiogroup" aria-label={label} className={cn("flex flex-wrap items-center gap-2", className)}>
      {options.map((o, i) => {
        const on = o === value;
        return (
          <button
            key={o}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={on}
            tabIndex={on ? 0 : -1}
            onClick={() => pick(o)}
            onKeyDown={(e) => {
              const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
              if (!step) return;
              e.preventDefault();
              const n = (i + step + options.length) % options.length;
              pick(options[n]);
              refs.current[n]?.focus();
            }}
            className={cn("h-9 cursor-pointer rounded-full border px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", on ? "border-ember bg-ember text-ember-foreground" : "bg-card text-muted-foreground hover:text-foreground")}
          >
            {o}
          </button>
        );
      })}
    </div>
  );
}
```