Payment Method Selector
A radio-driven list of payment method rows, wrapping Morphos's RadioGroup + Radio.
Installation
npx kosmesis add payment-method-selectorpnpm dlx kosmesis add payment-method-selectoryarn dlx kosmesis add payment-method-selectorbunx kosmesis add payment-method-selectorInstall 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 type { Children } from "@praxisjs/shared";import { Radio as MorphosRadio, RadioGroup as MorphosRadioGroup, type RadioProps as MorphosRadioProps } from "@morphos/inputs";import { cn } from "@/lib/utils";// Extends (not wraps) `RadioGroup` for the same reason `RadioGroup` itself does.@Component()export class PaymentMethodSelector extends MorphosRadioGroup { render() { return ( <div id={this.id} role="radiogroup" class={cn("flex flex-col gap-2", this.class)} aria-label={this["aria-label"]} aria-labelledby={this["aria-labelledby"]} data-disabled={this.disabled ? "" : undefined} > {this.children} </div> ); }}export interface PaymentMethodOptionProps extends MorphosRadioProps { label: Children; icon?: Children;}@Component()export class PaymentMethodOption extends StatelessComponent<PaymentMethodOptionProps> { render() { const { label, icon, class: cls, ...rest } = this.props; return ( <MorphosRadio class={cn( "relative flex cursor-pointer items-center gap-3 rounded-lg border border-input bg-background p-3 text-sm outline-none transition-colors", "hover:bg-accent/50", "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring", "data-checked:border-primary data-checked:ring-1 data-checked:ring-primary", "data-disabled:cursor-not-allowed data-disabled:opacity-50", "[&_input]:absolute [&_input]:inset-0 [&_input]:size-full [&_input]:cursor-pointer [&_input]:opacity-0", cls, )} {...rest} > {icon} <span class="flex-1">{label}</span> <span class={cn( "relative flex size-4 shrink-0 items-center justify-center rounded-full border border-input", "after:absolute after:size-2 after:scale-0 after:rounded-full after:bg-primary after:transition-transform", "in-data-[checked]:border-primary in-data-[checked]:after:scale-100", )} /> </MorphosRadio> ); }}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 type { Children } from "@praxisjs/shared";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 PaymentMethodStyles extends Stylesheet { $group = this.css({ display: "flex", flexDirection: "column", gap: "0.5rem" }); $option = this.css({ position: "relative", display: "flex", cursor: "pointer", alignItems: "center", gap: "0.75rem", borderRadius: "0.5rem", border: `1px solid ${t.input}`, backgroundColor: t.background, padding: "0.75rem", fontSize: "0.875rem", outline: "none", transition: "border-color 120ms ease, box-shadow 120ms ease", }) .on("&:hover", { backgroundColor: `color-mix(in oklab, ${t.accent} 50%, transparent)` }) .focusVisible({ borderColor: t.ring, boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` }) .on("&[data-checked]", { borderColor: t.primary, boxShadow: `0 0 0 1px ${t.primary}` }) .on("&[data-disabled]", { cursor: "not-allowed", opacity: 0.5 }) .on("& input", { position: "absolute", inset: "0", width: "100%", height: "100%", cursor: "pointer", opacity: "0" }); $label = this.css({ flex: "1 1 0%" }); $dot = this.css({ position: "relative", display: "flex", height: "1rem", width: "1rem", flexShrink: 0, alignItems: "center", justifyContent: "center", borderRadius: "9999px", border: `1px solid ${t.input}`, }) .after({ content: '""', position: "absolute", height: "0.5rem", width: "0.5rem", transform: "scale(0)", borderRadius: "9999px", backgroundColor: t.primary, transition: "transform 120ms ease", }) .on("[data-checked] &", { borderColor: t.primary }) .on("[data-checked] &::after", { transform: "scale(1)" });}// Extends (not wraps) `RadioGroup` for the same reason `RadioGroup` itself does.@Component()export class PaymentMethodSelector extends MorphosRadioGroup { @Styled(PaymentMethodStyles) $s!: PaymentMethodStyles; render() { return ( <div id={this.id} role="radiogroup" class={cx(this.$s.$group, this.class)} aria-label={this["aria-label"]} aria-labelledby={this["aria-labelledby"]} data-disabled={this.disabled ? "" : undefined} > {this.children} </div> ); }}export interface PaymentMethodOptionProps extends MorphosRadioProps { label: Children; icon?: Children;}@Component()export class PaymentMethodOption extends StatelessComponent<PaymentMethodOptionProps> { @Styled(PaymentMethodStyles) $s!: PaymentMethodStyles; render() { const { label, icon, class: cls, ...rest } = this.props; return ( <MorphosRadio class={cx(this.$s.$option, cls)} {...rest}> {icon} <span class={this.$s.$label}>{label}</span> <span class={this.$s.$dot} /> </MorphosRadio> ); }}Examples
About
Same Morphos RadioGroup/Radio primitives as RadioGroup/RadioCard, styled as a vertical list
of rows (icon + label + trailing dot) instead of a grid of cards. Same two-instance pattern as
RadioGroup: one instance mounted via JSX (the container), one held in state that
PaymentMethodOption reads from via its group prop.
Usage
import { PaymentMethodOption, PaymentMethodSelector } from "@/components/ui/payment-method-selector";
@Component()
class PaymentMethod extends StatefulComponent {
@State() group = new PaymentMethodSelector({ defaultValue: "card" });
onBeforeMount() {
this.group.onBeforeMount();
}
render() {
return (
<PaymentMethodSelector defaultValue="card">
<PaymentMethodOption group={this.group} value="card" label="Credit card" icon={<CreditCardIcon />} />
<PaymentMethodOption group={this.group} value="paypal" label="PayPal" icon={<PayPalIcon />} />
</PaymentMethodSelector>
);
}
}Props
PaymentMethodSelector accepts the same props as RadioGroup (defaultValue, disabled,
class, ...). PaymentMethodOption accepts the same props as RadioGroupItem (group, value,
disabled, class, ...) plus label and an optional icon.