Kosmesis
Components

Radio Card

A card-shaped radio option, wrapping Morphos's RadioGroup + Radio.

Installation

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

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-card.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: `new RadioCardGroup(...)` must still yield an instance with .selectedValue/.select().@Component()export class RadioCardGroup extends MorphosRadioGroup {  render() {    return (      <div        id={this.id}        role="radiogroup"        class={cn("grid gap-3 sm:grid-cols-2", 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 RadioCardProps = MorphosRadioProps;// The input is stretched transparent over the whole card so the entire area is clickable; the checked dot is a before/after pair driven by data-checked.@Component()export class RadioCard extends StatelessComponent<RadioCardProps> {  render() {    const { class: cls, children, ...rest } = this.props;    return (      <MorphosRadio        class={cn(          "relative flex cursor-pointer flex-col gap-1 rounded-lg border border-input bg-background p-4 pr-8 shadow-xs 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",          "after:absolute after:top-3.5 after:right-3.5 after:size-4 after:rounded-full after:border after:border-input after:bg-background after:transition-colors data-checked:after:border-primary data-checked:after:bg-primary",          "before:absolute before:top-[1.1875rem] before:right-[1.1875rem] before:z-10 before:size-1.5 before:scale-0 before:rounded-full before:bg-primary-foreground before:transition-transform data-checked:before:scale-100",          cls,        )}        {...rest}      >        {children}      </MorphosRadio>    );  }}export interface RadioCardSlotProps {  class?: string;  children?: Children;}@Component()export class RadioCardTitle extends StatelessComponent<RadioCardSlotProps> {  render() {    const { class: cls, children } = this.props;    return <p class={cn("text-sm font-medium text-foreground", cls)}>{children}</p>;  }}@Component()export class RadioCardDescription extends StatelessComponent<RadioCardSlotProps> {  render() {    const { class: cls, children } = this.props;    return <p class={cn("text-xs text-muted-foreground", cls)}>{children}</p>;  }}

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-card.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 RadioCardStyles extends Stylesheet {  $group = this.css({ display: "grid", gap: "0.75rem" });  $card = this.css({    position: "relative",    display: "flex",    cursor: "pointer",    flexDirection: "column",    gap: "0.25rem",    borderRadius: "0.5rem",    border: `1px solid ${t.input}`,    backgroundColor: t.background,    padding: "1rem",    paddingRight: "2rem",    boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",    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" })    .after({      content: '""',      position: "absolute",      top: "0.875rem",      right: "0.875rem",      height: "1rem",      width: "1rem",      borderRadius: "9999px",      border: `1px solid ${t.input}`,      backgroundColor: t.background,      transition: "border-color 120ms ease, background-color 120ms ease",    })    .on("&[data-checked]::after", { borderColor: t.primary, backgroundColor: t.primary })    .before({      content: '""',      position: "absolute",      zIndex: 10,      top: "1.1875rem",      right: "1.1875rem",      height: "0.375rem",      width: "0.375rem",      transform: "scale(0)",      borderRadius: "9999px",      backgroundColor: t.primaryForeground,      transition: "transform 120ms ease",    })    .on("&[data-checked]::before", { transform: "scale(1)" });  $title = this.css({ fontSize: "0.875rem", fontWeight: 500, color: t.foreground });  $description = this.css({ fontSize: "0.75rem", color: t.mutedForeground });}// Extends (not wraps) RadioGroup: `new RadioCardGroup(...)` must still yield an instance with .selectedValue/.select().@Component()export class RadioCardGroup extends MorphosRadioGroup {  @Styled(RadioCardStyles) $s!: RadioCardStyles;  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-orientation={this.orientation}        data-disabled={this.disabled ? "" : undefined}      >        {this.children}      </div>    );  }}export type RadioCardProps = MorphosRadioProps;// The input is stretched transparent over the whole card so the entire area is clickable; the checked dot is a before/after pair driven by data-checked.@Component()export class RadioCard extends StatelessComponent<RadioCardProps> {  @Styled(RadioCardStyles) $s!: RadioCardStyles;  render() {    const { class: cls, children, ...rest } = this.props;    return (      <MorphosRadio class={cx(this.$s.$card, cls)} {...rest}>        {children}      </MorphosRadio>    );  }}export interface RadioCardSlotProps {  class?: string;  children?: Children;}@Component()export class RadioCardTitle extends StatelessComponent<RadioCardSlotProps> {  @Styled(RadioCardStyles) $s!: RadioCardStyles;  render() {    const { class: cls, children } = this.props;    return <p class={cx(this.$s.$title, cls)}>{children}</p>;  }}@Component()export class RadioCardDescription extends StatelessComponent<RadioCardSlotProps> {  @Styled(RadioCardStyles) $s!: RadioCardStyles;  render() {    const { class: cls, children } = this.props;    return <p class={cx(this.$s.$description, cls)}>{children}</p>;  }}

Examples

About

Same Morphos RadioGroup/Radio primitives as RadioGroup/RadioGroupItem, styled as a grid of selectable cards instead of a list of dots. Two instances are involved, same as RadioGroup: one mounted via JSX (produces the container <div>), one held in state that RadioCard reads from via its group prop.

Usage

import { RadioCard, RadioCardDescription, RadioCardGroup, RadioCardTitle } from "@/components/ui/radio-card";

@Component()
class PlanPicker extends StatefulComponent {
  @State() group = new RadioCardGroup({ defaultValue: "pro" });

  onBeforeMount() {
    this.group.onBeforeMount();
  }

  render() {
    return (
      <RadioCardGroup defaultValue="pro">
        <RadioCard group={this.group} value="free">
          <RadioCardTitle>Free</RadioCardTitle>
          <RadioCardDescription>For personal projects</RadioCardDescription>
        </RadioCard>
        <RadioCard group={this.group} value="pro">
          <RadioCardTitle>Pro</RadioCardTitle>
          <RadioCardDescription>For growing teams</RadioCardDescription>
        </RadioCard>
      </RadioCardGroup>
    );
  }
}

Props

RadioCardGroup accepts the same props as RadioGroup (defaultValue, disabled, orientation, class, ...). RadioCard accepts the same props as RadioGroupItem (group, value, disabled, class, ...). RadioCardTitle/RadioCardDescription accept class and children only.

On this page