Kosmesis
Components

Credit Card

A visual credit card face with multiple color variants and a flippable front/back. Purely presentational — no Morphos equivalent.

Installation

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

Install the following dependencies:

npm install class-variance-authority
pnpm add class-variance-authority
yarn add class-variance-authority
bun add class-variance-authority

Copy and paste the following code into your project.

credit-card.tsx
import { cva, type VariantProps } from "class-variance-authority";import { StatefulComponent } from "@praxisjs/core";import { Component, FunctionProp, Prop, State } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export const creditCardVariants = cva(  "absolute inset-0 flex flex-col justify-between overflow-hidden rounded-xl p-5 shadow-lg [backface-visibility:hidden]",  {    variants: {      variant: {        dark: "bg-gradient-to-br from-neutral-800 to-neutral-950 text-white",        gradient: "bg-gradient-to-br from-violet-600 via-fuchsia-600 to-pink-600 text-white",        gold: "bg-gradient-to-br from-amber-300 via-yellow-500 to-amber-700 text-neutral-900",        platinum: "bg-gradient-to-br from-slate-300 via-slate-400 to-slate-500 text-neutral-900",        light: "border border-neutral-200 bg-gradient-to-br from-white to-neutral-100 text-neutral-900",      },    },    defaultVariants: { variant: "dark" },  },);const chipVariants = cva("h-8 w-11 rounded-md", {  variants: {    variant: {      dark: "bg-gradient-to-br from-yellow-300 to-yellow-500",      gradient: "bg-gradient-to-br from-white/90 to-white/50",      gold: "bg-gradient-to-br from-neutral-800 to-neutral-950",      platinum: "bg-gradient-to-br from-neutral-700 to-neutral-900",      light: "bg-gradient-to-br from-neutral-700 to-neutral-900",    },  },  defaultVariants: { variant: "dark" },});export interface CreditCardProps extends VariantProps<typeof creditCardVariants> {  number?: string;  name?: string;  expiry?: string;  brand?: string;  cvv?: string;  flippable?: boolean;  /**   * Prop updates aren't continuously reactive here — to change `side` after the first render,   * remount via a thunk keyed off your own state (e.g. `{() => <CreditCard side={this.side} />}`).   * That switches instantly instead of animating; use `flippable` for the smooth 3D flip.   */  side?: "front" | "back";  onFlip?: (side: "front" | "back") => void;  class?: string;}// Both faces are `absolute`, so nothing in normal flow gives the outer box a size; `min-w-64`// keeps it from collapsing to 0×0 inside a shrink-to-fit parent (`width: 100%` of an indefinite// containing block falls back to `fit-content` of a now content-less box).@Component()export class CreditCard extends StatefulComponent {  @Prop() number = "•••• •••• •••• ••••";  @Prop() name = "CARD HOLDER";  @Prop() expiry = "MM/YY";  @Prop() brand?: string;  @Prop() cvv = "•••";  @Prop() variant: CreditCardProps["variant"] = "dark";  @Prop() flippable = true;  @Prop() side?: "front" | "back";  @FunctionProp() onFlip?: CreditCardProps["onFlip"];  @Prop() class?: string;  @State() private _flipped = false;  private get _isFlipped(): boolean {    return this.side ? this.side === "back" : this._flipped;  }  private readonly _flip = () => {    if (!this.flippable) return;    const next: "front" | "back" = this._isFlipped ? "front" : "back";    if (!this.side) this._flipped = !this._flipped;    this.onFlip?.(next);  };  private readonly _handleKeyDown = (event: KeyboardEvent) => {    if (!this.flippable) return;    if (event.key === "Enter" || event.key === " ") {      event.preventDefault();      this._flip();    }  };  render() {    return (      <div        data-slot="credit-card"        class={cn("relative aspect-[1.586/1] w-full min-w-64 max-w-sm [perspective:1000px]", this.class)}        role={this.flippable ? "button" : undefined}        tabIndex={this.flippable ? 0 : undefined}        aria-label={this.flippable ? "Flip card" : undefined}        onClick={this._flip}        onKeyDown={this._handleKeyDown}      >        <div          class={cn("relative h-full w-full transition-transform duration-500 [transform-style:preserve-3d]", this.flippable && "cursor-pointer")}          style={() => ({ transform: this._isFlipped ? "rotateY(180deg)" : "rotateY(0deg)" })}        >          <div class={creditCardVariants({ variant: this.variant })}>            <div class="flex items-center justify-between">              <div class={chipVariants({ variant: this.variant })} />              {this.brand && <span class="text-sm font-semibold italic">{this.brand}</span>}            </div>            <div class="overflow-hidden font-mono text-base whitespace-nowrap tracking-wide text-ellipsis">{this.number}</div>            <div class="flex items-end justify-between text-xs">              <span class="uppercase tracking-wide">{this.name}</span>              <span>{this.expiry}</span>            </div>          </div>          <div class={cn(creditCardVariants({ variant: this.variant }), "[transform:rotateY(180deg)]")}>            <div class="-mx-5 mt-4 h-10 bg-neutral-900" />            <div class="flex h-8 items-center justify-end rounded-sm bg-white px-3">              <span class="font-mono text-xs italic text-neutral-900">{this.cvv}</span>            </div>            <div class="flex items-center justify-between text-[10px] uppercase tracking-wide opacity-70">              <span>Customer service</span>              {this.brand && <span class="text-sm font-semibold italic normal-case">{this.brand}</span>}            </div>          </div>        </div>      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

credit-card.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled } from "@praxisjs/css";import { Component, FunctionProp, Prop, State } from "@praxisjs/decorators";export type CreditCardVariant = "dark" | "gradient" | "gold" | "platinum" | "light";class CreditCardStyles extends Stylesheet {  $outer = this.css({    position: "relative",    aspectRatio: "1.586 / 1",    width: "100%",    minWidth: "16rem",    maxWidth: "24rem",    perspective: "1000px",  });  $flipper = this.css({    position: "relative",    height: "100%",    width: "100%",    transition: "transform 500ms ease",    transformStyle: "preserve-3d",  });  $flipperCursor = this.css({ cursor: "pointer" });  $face = this.css({    position: "absolute",    inset: "0",    display: "flex",    flexDirection: "column",    justifyContent: "space-between",    overflow: "hidden",    borderRadius: "0.75rem",    padding: "1.25rem",    boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",    backfaceVisibility: "hidden",  });  $back = this.css({ transform: "rotateY(180deg)" });  $variantDark = this.css({ background: "linear-gradient(to bottom right, oklch(0.28 0 0), oklch(0.13 0 0))", color: "white" });  $variantGradient = this.css({    background: "linear-gradient(to bottom right, oklch(0.54 0.25 293), oklch(0.59 0.24 330), oklch(0.65 0.24 355))",    color: "white",  });  $variantGold = this.css({    background: "linear-gradient(to bottom right, oklch(0.87 0.15 95), oklch(0.79 0.17 80), oklch(0.6 0.14 65))",    color: "oklch(0.2 0 0)",  });  $variantPlatinum = this.css({    background: "linear-gradient(to bottom right, oklch(0.85 0 0), oklch(0.75 0 0), oklch(0.65 0 0))",    color: "oklch(0.2 0 0)",  });  $variantLight = this.css({    background: "linear-gradient(to bottom right, white, oklch(0.96 0 0))",    color: "oklch(0.2 0 0)",    border: "1px solid oklch(0.9 0 0)",  });  $header = this.css({ display: "flex", alignItems: "center", justifyContent: "space-between" });  $chip = this.css({ height: "2rem", width: "2.75rem", borderRadius: "0.375rem" });  $chipDark = this.css({ background: "linear-gradient(to bottom right, oklch(0.87 0.15 95), oklch(0.75 0.15 80))" });  $chipGradient = this.css({ background: "linear-gradient(to bottom right, rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.5))" });  $chipGold = this.css({ background: "linear-gradient(to bottom right, oklch(0.28 0 0), oklch(0.13 0 0))" });  $chipPlatinum = this.css({ background: "linear-gradient(to bottom right, oklch(0.35 0 0), oklch(0.15 0 0))" });  $chipLight = this.css({ background: "linear-gradient(to bottom right, oklch(0.35 0 0), oklch(0.15 0 0))" });  $brand = this.css({ fontSize: "0.875rem", fontWeight: 600, fontStyle: "italic" });  $brandBack = this.css({ fontSize: "0.875rem", fontWeight: 600, fontStyle: "italic", textTransform: "none" });  $number = this.css({    overflow: "hidden",    fontFamily: "ui-monospace, monospace",    fontSize: "1rem",    letterSpacing: "0.05em",    whiteSpace: "nowrap",    textOverflow: "ellipsis",  });  $footer = this.css({ display: "flex", alignItems: "flex-end", justifyContent: "space-between", fontSize: "0.75rem" });  $name = this.css({ textTransform: "uppercase", letterSpacing: "0.05em" });  $stripe = this.css({ marginInline: "-1.25rem", marginTop: "1rem", height: "2.5rem", backgroundColor: "oklch(0.15 0 0)" });  $signature = this.css({    display: "flex",    height: "2rem",    alignItems: "center",    justifyContent: "flex-end",    borderRadius: "0.125rem",    backgroundColor: "white",    padding: "0 0.75rem",  });  $cvv = this.css({ fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", fontStyle: "italic", color: "oklch(0.2 0 0)" });  $backFooter = this.css({    display: "flex",    alignItems: "center",    justifyContent: "space-between",    fontSize: "0.625rem",    textTransform: "uppercase",    letterSpacing: "0.05em",    opacity: 0.7,  });}const VARIANT_FACE: Record<CreditCardVariant, keyof CreditCardStyles> = {  dark: "$variantDark",  gradient: "$variantGradient",  gold: "$variantGold",  platinum: "$variantPlatinum",  light: "$variantLight",};const VARIANT_CHIP: Record<CreditCardVariant, keyof CreditCardStyles> = {  dark: "$chipDark",  gradient: "$chipGradient",  gold: "$chipGold",  platinum: "$chipPlatinum",  light: "$chipLight",};export interface CreditCardProps {  number?: string;  name?: string;  expiry?: string;  brand?: string;  cvv?: string;  variant?: CreditCardVariant;  flippable?: boolean;  /**   * Prop updates aren't continuously reactive here — to change `side` after the first render,   * remount via a thunk keyed off your own state (e.g. `{() => <CreditCard side={this.side} />}`).   * That switches instantly instead of animating; use `flippable` for the smooth 3D flip.   */  side?: "front" | "back";  onFlip?: (side: "front" | "back") => void;  class?: string;}// Both faces are `position: absolute`, so nothing in normal flow gives `$outer` a size; its// `minWidth` keeps it from collapsing to 0×0 inside a shrink-to-fit parent (`width: 100%` of an// indefinite containing block falls back to `fit-content` of a now content-less box).@Component()export class CreditCard extends StatefulComponent {  @Styled(CreditCardStyles) $s!: CreditCardStyles;  @Prop() number = "•••• •••• •••• ••••";  @Prop() name = "CARD HOLDER";  @Prop() expiry = "MM/YY";  @Prop() brand?: string;  @Prop() cvv = "•••";  @Prop() variant: CreditCardVariant = "dark";  @Prop() flippable = true;  @Prop() side?: "front" | "back";  @FunctionProp() onFlip?: CreditCardProps["onFlip"];  @Prop() class?: string;  @State() private _flipped = false;  private get _isFlipped(): boolean {    return this.side ? this.side === "back" : this._flipped;  }  private readonly _flip = () => {    if (!this.flippable) return;    const next: "front" | "back" = this._isFlipped ? "front" : "back";    if (!this.side) this._flipped = !this._flipped;    this.onFlip?.(next);  };  private readonly _handleKeyDown = (event: KeyboardEvent) => {    if (!this.flippable) return;    if (event.key === "Enter" || event.key === " ") {      event.preventDefault();      this._flip();    }  };  render() {    const faceClass = this.$s[VARIANT_FACE[this.variant]];    const chipClass = this.$s[VARIANT_CHIP[this.variant]];    return (      <div        data-slot="credit-card"        class={cx(this.$s.$outer, this.class)}        role={this.flippable ? "button" : undefined}        tabIndex={this.flippable ? 0 : undefined}        aria-label={this.flippable ? "Flip card" : undefined}        onClick={this._flip}        onKeyDown={this._handleKeyDown}      >        <div class={cx(this.$s.$flipper, this.flippable && this.$s.$flipperCursor)} style={() => ({ transform: this._isFlipped ? "rotateY(180deg)" : "rotateY(0deg)" })}>          <div class={cx(this.$s.$face, faceClass)}>            <div class={this.$s.$header}>              <div class={cx(this.$s.$chip, chipClass)} />              {this.brand && <span class={this.$s.$brand}>{this.brand}</span>}            </div>            <div class={this.$s.$number}>{this.number}</div>            <div class={this.$s.$footer}>              <span class={this.$s.$name}>{this.name}</span>              <span>{this.expiry}</span>            </div>          </div>          <div class={cx(this.$s.$face, faceClass, this.$s.$back)}>            <div class={this.$s.$stripe} />            <div class={this.$s.$signature}>              <span class={this.$s.$cvv}>{this.cvv}</span>            </div>            <div class={this.$s.$backFooter}>              <span>Customer service</span>              {this.brand && <span class={this.$s.$brandBack}>{this.brand}</span>}            </div>          </div>        </div>      </div>    );  }}

Examples

About

Purely presentational — no Morphos equivalent. Pass an already-masked number (e.g. "•••• •••• •••• 4242") if you're displaying a saved card; this component does no formatting or validation of its own.

variant swaps the whole color scheme ("dark" (default), "gradient", "gold", "platinum", "light") — front and back always share it, so it still reads as one physical card rather than two unrelated faces. When flippable (default true), clicking the card — or focusing it and pressing Enter/Space — rotates it 180° via a real 3D transform (perspective on the outer box, rotateY on an inner flipper, backface-visibility: hidden on both faces) to reveal the back: a magnetic stripe, a signature strip with cvv, and the brand repeated in small print. Both faces are always in the DOM; rotateY alone decides which one is currently readable, so there's no separate "back-facing" render path to keep in sync.

Both faces are absolutely positioned (so the flip doesn't reflow anything), which means nothing in normal document flow gives the card a natural size anymore — a min-width keeps it from collapsing to 0×0 if you drop it into a shrink-to-fit parent (a centered flex column with no explicit width); it still happily fills (up to its max-width) any parent that has a real one, which is the common case.

side lets you pick which face is showing from the outside instead of relying on clicks — pass flippable={false} alongside it to disable the built-in click-to-flip entirely, as in the Controlled example above. onFlip fires on every flip either way (controlled or not), so a controlled consumer can keep its own state in sync. Cross-component prop updates aren't continuously reactive in this framework (a value is only read once at assignment), so changing side after the first render means re-creating the element from a thunk keyed off your own state — e.g. {() => <CreditCard side={this.side} .../>}, the same idiom ProgressiveBlur uses — which is why an externally-driven side switches instantly rather than animating; reserve flippable + the card's own internal state for the smooth 3D flip.

Usage

import { CreditCard } from "@/components/ui/credit-card";

<CreditCard
  variant="gradient"
  number="4242 4242 4242 4242"
  name="Ada Lovelace"
  expiry="08/29"
  brand="VISA"
  cvv="123"
/>

Props

PropTypeDefault
numberstring"•••• •••• •••• ••••"
namestring"CARD HOLDER"
expirystring"MM/YY"
brandstring
cvvstring (shown on the back)"•••"
variant"dark" | "gradient" | "gold" | "platinum" | "light""dark"
flippablebooleantrue
side"front" | "back" (controlled — omit for internal state)
onFlip(side: "front" | "back") => void
classstring

On this page