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-cardpnpm dlx kosmesis add credit-cardyarn dlx kosmesis add credit-cardbunx kosmesis add credit-cardInstall the following dependencies:
npm install class-variance-authoritypnpm add class-variance-authorityyarn add class-variance-authoritybun add class-variance-authorityCopy and paste the following code into your project.
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/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
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
| Prop | Type | Default |
|---|---|---|
number | string | "•••• •••• •••• ••••" |
name | string | "CARD HOLDER" |
expiry | string | "MM/YY" |
brand | string | — |
cvv | string (shown on the back) | "•••" |
variant | "dark" | "gradient" | "gold" | "platinum" | "light" | "dark" |
flippable | boolean | true |
side | "front" | "back" (controlled — omit for internal state) | — |
onFlip | (side: "front" | "back") => void | — |
class | string | — |