Kosmesis
Components

Radio Group

A radio group with keyboard navigation, wrapping Morphos's RadioGroup + Radio.

Installation

npx kosmesis add radio-group
pnpm dlx kosmesis add radio-group
yarn dlx kosmesis add radio-group
bunx kosmesis add radio-group

Install the following dependencies:

npm install @morphos/inputs
pnpm add @morphos/inputs
yarn add @morphos/inputs
bun add @morphos/inputs

Copy and paste the following code into your project.

radio-group.tsx
import { StatelessComponent } from "@praxisjs/core";import { Component } from "@praxisjs/decorators";import { Radio as MorphosRadio, RadioGroup as MorphosRadioGroup, type RadioProps as MorphosRadioProps  } from "@morphos/inputs";import { cn } from "@/lib/utils";/** * Extends (not wraps) Morphos's `RadioGroup` so `new RadioGroup({ defaultValue: "a" })` still * yields a real instance with `.selectedValue`/`.select()` — what `RadioGroupItem` needs via its * `group` prop. */@Component()export class RadioGroup extends MorphosRadioGroup {  render() {    return (      <div        id={this.id}        role="radiogroup"        class={cn("grid gap-3", this.orientation === "horizontal" && "flex gap-4", this.class)}        aria-label={this["aria-label"]}        aria-labelledby={this["aria-labelledby"]}        data-orientation={this.orientation}        data-disabled={this.disabled ? "" : undefined}      >        {this.children}      </div>    );  }}export type RadioGroupItemProps = MorphosRadioProps;/** * Morphos's `Radio` renders a `<label>` wrapping the native input plus `children` — there is no * separate "indicator" part, so the checked dot is rendered here as an `::after` pseudo-element * on that same label, driven by the `data-checked` attribute Morphos already sets on it. */@Component()export class RadioGroupItem extends StatelessComponent<RadioGroupItemProps> {  render() {    const { class: cls, ...rest } = this.props;    return (      <MorphosRadio        class={cn(          "relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-transparent shadow-xs outline-none transition-shadow focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-checked:border-primary [&_input]:absolute [&_input]:inset-0 [&_input]:size-full [&_input]:cursor-pointer [&_input]:opacity-0 after:absolute after:size-2 after:scale-0 after:rounded-full after:bg-primary after:transition-transform data-checked:after:scale-100",          cls,        )}        {...rest}      />    );  }}

Install the following dependencies:

npm install @morphos/inputs @praxisjs/css
pnpm add @morphos/inputs @praxisjs/css
yarn add @morphos/inputs @praxisjs/css
bun add @morphos/inputs @praxisjs/css

Copy and paste the following code into your project.

radio-group.tsx
import { StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component } from "@praxisjs/decorators";import { Radio as MorphosRadio, RadioGroup as MorphosRadioGroup, type RadioProps as MorphosRadioProps  } from "@morphos/inputs";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class RadioGroupStyles extends Stylesheet {  $root = this.css({ display: "grid", gap: "0.75rem" }).on('&[data-orientation="horizontal"]', {    display: "flex",    gap: "1rem",  });  $item = this.css({    position: "relative",    display: "inline-flex",    width: "1rem",    height: "1rem",    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    borderRadius: "9999px",    border: `1px solid ${t.input}`,    backgroundColor: "transparent",    boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",    outline: "none",    transition: "box-shadow 120ms ease",    cursor: "pointer",  })    .focusVisible({ borderColor: t.ring, boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` })    .disabled({ cursor: "not-allowed", opacity: 0.5 })    .on("&[data-disabled]", { cursor: "not-allowed", opacity: 0.5 })    .on("&[data-checked]", { borderColor: t.primary })    .on("& input", { position: "absolute", inset: "0", width: "100%", height: "100%", cursor: "pointer", opacity: "0" })    .after({      content: '""',      position: "absolute",      width: "0.5rem",      height: "0.5rem",      transform: "scale(0)",      borderRadius: "9999px",      backgroundColor: t.primary,      transition: "transform 120ms ease",    })    .on("&[data-checked]::after", { transform: "scale(1)" });}/** * Extends (not wraps) Morphos's `RadioGroup` so `new RadioGroup({ defaultValue: "a" })` still * yields a real instance with `.selectedValue`/`.select()` — what `RadioGroupItem` needs via its * `group` prop. */@Component()export class RadioGroup extends MorphosRadioGroup {  @Styled(RadioGroupStyles) $s!: RadioGroupStyles;  render() {    return (      <div        id={this.id}        role="radiogroup"        class={cx(this.$s.$root, this.class)}        aria-label={this["aria-label"]}        aria-labelledby={this["aria-labelledby"]}        data-orientation={this.orientation}        data-disabled={this.disabled ? "" : undefined}      >        {this.children}      </div>    );  }}export type RadioGroupItemProps = MorphosRadioProps;/** * Morphos's `Radio` renders a `<label>` wrapping the native input plus `children` — there is no * separate "indicator" part, so the checked dot is rendered here as an `::after` pseudo-element * on that same label, driven by the `data-checked` attribute Morphos already sets on it. */@Component()export class RadioGroupItem extends StatelessComponent<RadioGroupItemProps> {  @Styled(RadioGroupStyles) $s!: RadioGroupStyles;  render() {    const { class: cls, ...rest } = this.props;    return <MorphosRadio class={cx(this.$s.$item, cls)} {...rest} />;  }}

Examples

Usage

import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";

@Component()
class PlanPicker extends StatefulComponent {
  // Referenced by each RadioGroupItem via its `group` prop.
  @State() plan = new RadioGroup({ defaultValue: "pro" });

  render() {
    return (
      <RadioGroup orientation="vertical" aria-label="Plan" class="gap-3">
        <div class="flex items-center gap-2">
          <RadioGroupItem group={this.plan} value="free" id="free" />
          <Label htmlFor="free">Free</Label>
        </div>
        <div class="flex items-center gap-2">
          <RadioGroupItem group={this.plan} value="pro" id="pro" />
          <Label htmlFor="pro">Pro</Label>
        </div>
      </RadioGroup>
    );
  }
}

The <RadioGroup> element mounted in the tree and the this.plan instance passed to each RadioGroupItem are deliberately two separate objects — Morphos has no context system, so RadioGroup produces the actual accessible container (role, aria-label, data-*) while a second instance (created once, e.g. in @State()) is what RadioGroupItem reads selection from. Keep their config props (like defaultValue) in sync.

On this page