Lunia DS

Base components

Radio group

A group of options where only one can be chosen, as plain radios or as selectable cards, where the chosen dot grows in from a small scale and the card border turns brand.

Loading preview

A plan picker with three Icon card items (Basic plan, Business plan with the badge "Limited time only", Enterprise plan), a plain radio list for "Email me about" with three options, and a Payment icon list with two cards and Set as default and Edit actions. Replay chooses each plan card in turn.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add radio-group

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Badge } from '../badge/badge';
import { Button } from '../button/button';
import { RadioGroup, RadioGroupItem } from './radio-group';

const STEP_MS = 500;
const PLANS = [
  {
    value: 'basic',
    label: 'Basic plan',
    price: '$10',
    text: 'Up to 10 people and 20 GB of storage.',
    icon: 'M8 2 2 5l6 3 6-3-6-3ZM2 8l6 3 6-3M2 11l6 3 6-3',
  },
  {
    value: 'business',
    label: 'Business plan',
    price: '$20',
    text: 'Up to 20 people and 40 GB of storage.',
    icon: 'M9 1.5 3 9h5l-1 5.5L13 7H8l1-5.5Z',
  },
  {
    value: 'enterprise',
    label: 'Enterprise plan',
    price: '$40',
    text: 'No limit on people or storage.',
    icon: 'M2.5 14V3.5l5-2v12.5M7.5 6h6v8M1.5 14h13M4.5 5.5v0M4.5 8.5v0M10 9v0M10 11.5v0',
  },
];
const CARDS = [
  { value: 'card-1234', label: 'Card ending in 1234', expiry: 'Expires 06/2028' },
  { value: 'card-5678', label: 'Card ending in 5678', expiry: 'Expires 11/2027' },
];

function FeaturedIcon({ path }: { path: string }) {
  return (
    <span className="flex size-8 items-center justify-center rounded-md border border-primary bg-primary text-fg-secondary shadow-xs-skeuomorphic">
      <svg
        aria-hidden="true"
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.33"
        strokeLinecap="round"
        strokeLinejoin="round"
        className="size-4"
      >
        <path d={path} />
      </svg>
    </span>
  );
}

function CardIcon() {
  return (
    <svg aria-hidden="true" viewBox="0 0 34 24" fill="none">
      <rect
        x="0.5"
        y="0.5"
        width="33"
        height="23"
        rx="3.5"
        className="fill-(--lunia-bg-primary) stroke-(--lunia-border-secondary)"
      />
      <rect x="5" y="6" width="7" height="5" rx="1" className="fill-fg-quaternary" />
      <path d="M5 16h8M16 16h5M24 16h5" strokeLinecap="round" className="stroke-fg-quaternary" />
    </svg>
  );
}

export default function RadioGroupDemo({ replay = 0 }: { replay?: number }) {
  const [plan, setPlan] = React.useState('business');
  const [topic, setTopic] = React.useState('news');
  const [card, setCard] = React.useState('card-1234');

  React.useEffect(() => {
    if (replay === 0) return;
    // Each plan card is chosen in turn, then the first one again.
    const order = ['basic', 'business', 'enterprise', 'basic'];
    setPlan('enterprise');
    const timers = order.map((value, i) => window.setTimeout(() => setPlan(value), (i + 1) * STEP_MS));
    return () => {
      for (const t of timers) window.clearTimeout(t);
    };
  }, [replay]);

  return (
    <div className="grid w-full gap-x-8 gap-y-8 sm:grid-cols-2">
      <div className="flex flex-col gap-3">
        <span id="radio-demo-plan" className="text-sm font-medium text-secondary">
          Plan
        </span>
        <RadioGroup type="icon-card" aria-labelledby="radio-demo-plan" value={plan} onValueChange={setPlan}>
          {PLANS.map((p) => (
            <RadioGroupItem
              key={p.value}
              value={p.value}
              label={p.label}
              supportingText={p.text}
              media={<FeaturedIcon path={p.icon} />}
              badge={
                p.value === 'business' ? (
                  <Badge type="badge-modern" color="success" size="sm" dot>
                    Limited time only
                  </Badge>
                ) : undefined
              }
            >
              <span className="flex items-baseline gap-1">
                <span className="text-display-sm font-semibold text-primary">{p.price}</span>
                <span className="text-md text-tertiary">per month</span>
              </span>
            </RadioGroupItem>
          ))}
        </RadioGroup>
      </div>
      <div className="flex flex-col gap-8">
        <div className="flex flex-col gap-3">
          <span id="radio-demo-topic" className="text-sm font-medium text-secondary">
            Email me about
          </span>
          <RadioGroup aria-labelledby="radio-demo-topic" value={topic} onValueChange={setTopic}>
            <RadioGroupItem value="news" label="News and updates" />
            <RadioGroupItem value="tips" label="Tips and guides" />
            <RadioGroupItem value="none" label="Only account emails" />
          </RadioGroup>
        </div>
        <div className="flex flex-col gap-3">
          <span id="radio-demo-card" className="text-sm font-medium text-secondary">
            Payment method
          </span>
          <RadioGroup
            type="payment-icon"
            aria-labelledby="radio-demo-card"
            value={card}
            onValueChange={setCard}
          >
            {CARDS.map((c) => (
              <RadioGroupItem
                key={c.value}
                value={c.value}
                label={c.label}
                supportingText={c.expiry}
                media={<CardIcon />}
                actions={
                  <>
                    <Button hierarchy="link-gray" size="sm">
                      Set as default
                    </Button>
                    <Button hierarchy="link-color" size="sm">
                      Edit
                    </Button>
                  </>
                }
              />
            ))}
          </RadioGroup>
        </div>
      </div>
    </div>
  );
}

API

Parts: root (radiogroup), item: plain radio row (control, label, supporting text), item: card (border, control, featured icon or avatar or payment icon, title, supporting text, badge, actions), control (circle), dot.

PropTypeDefaultDescription
valuestringundefinedControlled chosen value.
defaultValuestringundefinedChosen value when uncontrolled.
type"plain" | "radio-button" | "icon-simple" | "icon-card" | "avatar" | "payment-icon""plain"plain is a radio with a label (the Figma Checkbox set, Type Radio); the others are the Figma card types.
size"sm" | "md""sm"Control of 16 or 20px and the matching text and card padding.
namestringundefinedForm field name.
requiredbooleanfalseA choice is needed before the form is sent.
disabledbooleanfalseTurns off every item. Items also take their own disabled prop.
orientation"vertical" | "horizontal""vertical"Direction of the stack; arrow keys work in both directions either way.
EventDescription
valueChangeA new item was chosen. Payload: its value.
SlotDescription
defaultRadioGroupItem children. Each item takes value, label, supportingText, disabled and the slots below.
mediaRadioGroupItem: featured icon, avatar or payment icon of a card.
badgeRadioGroupItem: badge on an Icon card, for example "Limited time only".
actionsRadioGroupItem: link buttons under the text of a Payment icon card, for example Set as default and Edit.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
RG1hover an unchosen itemcontrolbackground-color, border-colorbg-primary, border-control → bg-primary_hover, border-brandduration-sm (200)ease-outSwaps at once
RG2an item is chosencontrolbackground-color, border-colorbg-primary, border-control → bg-brand-solid, bg-brand-solidduration-sm (200)ease-outSwaps at once
RG3an item is chosendotopacity, scale0, scale-swap → 1, 1duration-md (260)ease-outdelay 60msShows at once
RG4the chosen item changesdot and control of the item that was chosenopacity, scale (dot); background-color, border-color (control)1, 1; bg-brand-solid → 0, scale-swap; bg-primary, border-controlduration-xs (160)ease-exitHides at once
RG5a card is chosen or leftcard borderbox-shadow (inset 2px border-brand over the 1px border-primary)none → inset 0 0 0 2px border-brandduration-sm (200)ease-outSwaps at once
RG6press (pointer down) on a plain radiocontrolscale1 → scale-pressduration-sm (200)ease-outStays still
RG7keyboard focuscontrol (cards: the card and its control)box-shadownone → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once

Accessibility

  • Built on a radio group primitive: role radiogroup with role radio items and aria-checked; one Tab stop, and the arrow keys move and choose.
  • Name the group with a visible legend or heading linked by aria-labelledby.
  • Card items are the radio themselves: the title is the name and the supporting text is linked with aria-describedby; buttons inside a card (actions) stay separate Tab stops after the group.
  • Plain radios without label text are 16 or 20px: give them a 24 x 24px tap area or 4px of space around them (WCAG 2.5.8). With text or as cards, the whole row is the tap area.
  • The card border change is not the only signal: the control shows the dot.
  • Reduced motion: the dot and the borders change at once and nothing scales.