Kosmesis
Components

Payment Method Selector

A radio-driven list of payment method rows, wrapping Morphos's RadioGroup + Radio.

Installation

npx kosmesis add payment-method-selector
pnpm dlx kosmesis add payment-method-selector
yarn dlx kosmesis add payment-method-selector
bunx kosmesis add payment-method-selector

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.

payment-method-selector.tsx
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/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.

payment-method-selector.tsx
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.

On this page