Radio Card
A card-shaped radio option, wrapping Morphos's RadioGroup + Radio.
Installation
npx kosmesis add radio-cardpnpm dlx kosmesis add radio-cardyarn dlx kosmesis add radio-cardbunx kosmesis add radio-cardInstall 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: `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/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 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.