Components
Radio Group
A radio group with keyboard navigation, wrapping Morphos's RadioGroup + Radio.
Installation
npx kosmesis add radio-grouppnpm dlx kosmesis add radio-groupyarn dlx kosmesis add radio-groupbunx kosmesis add radio-groupInstall the following dependencies:
npm install @morphos/inputspnpm add @morphos/inputsyarn add @morphos/inputsbun add @morphos/inputsCopy and paste the following code into your project.
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/csspnpm add @morphos/inputs @praxisjs/cssyarn add @morphos/inputs @praxisjs/cssbun add @morphos/inputs @praxisjs/cssCopy and paste the following code into your project.
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.