From 87039f8142579cdd86f0e9e06a67428cb1f7a6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 25 Jun 2026 00:04:24 +0200 Subject: [PATCH] Add v2 Card component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Card component from the Probo Radix UI Figma: a
container with size 1-5 (padding + radius), the surface/classic/ghost variants, and an interactive hover/active affordance. Add a CardInset part that bleeds content (e.g. a cover image) to the card edges, reading the card size from context so it negates the matching padding. Includes a paired CardSkeleton. Styling uses tailwind-variants/lite. Signed-off-by: Émile Ré --- packages/ui/src/v2/Card/Card.stories.tsx | 104 +++++++++++++++++++++ packages/ui/src/v2/Card/Card.tsx | 34 +++++++ packages/ui/src/v2/Card/CardInset.tsx | 33 +++++++ packages/ui/src/v2/Card/CardSkeleton.tsx | 27 ++++++ packages/ui/src/v2/Card/context.ts | 26 ++++++ packages/ui/src/v2/Card/variants.ts | 109 +++++++++++++++++++++++ 6 files changed, 333 insertions(+) create mode 100644 packages/ui/src/v2/Card/Card.stories.tsx create mode 100644 packages/ui/src/v2/Card/Card.tsx create mode 100644 packages/ui/src/v2/Card/CardInset.tsx create mode 100644 packages/ui/src/v2/Card/CardSkeleton.tsx create mode 100644 packages/ui/src/v2/Card/context.ts create mode 100644 packages/ui/src/v2/Card/variants.ts diff --git a/packages/ui/src/v2/Card/Card.stories.tsx b/packages/ui/src/v2/Card/Card.stories.tsx new file mode 100644 index 000000000..57becb186 --- /dev/null +++ b/packages/ui/src/v2/Card/Card.stories.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { Meta, StoryObj } from "@storybook/react"; + +import { Card } from "./Card"; +import { CardInset } from "./CardInset"; +import { CardSkeleton } from "./CardSkeleton"; + +const sizes = [1, 2, 3, 4, 5] as const; +const variants = ["surface", "classic", "ghost"] as const; + +function Sample() { + return ( +
+ Teodros Girmay + Engineering +
+ ); +} + +export default { + title: "v2/Card", + component: Card, + args: { + size: 1, + variant: "surface", + interactive: false, + }, + render: args => ( +
+ +
+ ), +} satisfies Meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Sizes: Story = { + render: () => ( +
+ {sizes.map(size => ( + + ))} +
+ ), +}; + +export const Variants: Story = { + render: () => ( +
+ {variants.map(variant => ( + + ))} +
+ ), +}; + +export const Interactive: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const Inset: Story = { + render: () => ( +
+ + + + + + +
+ ), +}; + +export const Skeleton: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/packages/ui/src/v2/Card/Card.tsx b/packages/ui/src/v2/Card/Card.tsx new file mode 100644 index 000000000..255a9e3aa --- /dev/null +++ b/packages/ui/src/v2/Card/Card.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ComponentProps } from "react"; +import type { VariantProps } from "tailwind-variants/lite"; + +import { CardProvider } from "./context"; +import { card } from "./variants"; + +export type CardProps = ComponentProps<"div"> & VariantProps; + +// Container that groups related content (Radix "Card"). Renders a
; for a +// clickable card set `interactive` and wrap the content in an anchor/link. Use +// CardInset for content that should bleed to the card's edges. +export function Card(props: CardProps) { + const { size = 1, variant, interactive, className, children, ...rest } = props; + + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/v2/Card/CardInset.tsx b/packages/ui/src/v2/Card/CardInset.tsx new file mode 100644 index 000000000..f0abc81af --- /dev/null +++ b/packages/ui/src/v2/Card/CardInset.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ComponentProps } from "react"; + +import { useCardContext } from "./context"; +import { cardInset } from "./variants"; + +export type CardInsetProps = ComponentProps<"div"> & { + // Which edges the content bleeds to. Defaults to all edges. + side?: "all" | "x" | "y" | "top" | "bottom"; +}; + +// Content that bleeds past the Card's padding to its edges (Radix "Inset"), +// e.g. a cover image. Negates the padding for the parent Card's size, read from +// context, so it always lines up. +export function CardInset(props: CardInsetProps) { + const { side = "all", className, ...rest } = props; + const size = useCardContext(); + + return
; +} diff --git a/packages/ui/src/v2/Card/CardSkeleton.tsx b/packages/ui/src/v2/Card/CardSkeleton.tsx new file mode 100644 index 000000000..a58fbddeb --- /dev/null +++ b/packages/ui/src/v2/Card/CardSkeleton.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ComponentProps } from "react"; +import type { VariantProps } from "tailwind-variants/lite"; + +import { cardSkeleton } from "./variants"; + +export type CardSkeletonProps = Omit, "children"> & VariantProps; + +// Loading placeholder paired with Card: a pulse block matching its size. +export function CardSkeleton(props: CardSkeletonProps) { + const { size, className, ...rest } = props; + + return
; +} diff --git a/packages/ui/src/v2/Card/context.ts b/packages/ui/src/v2/Card/context.ts new file mode 100644 index 000000000..0329db513 --- /dev/null +++ b/packages/ui/src/v2/Card/context.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { createContext, useContext } from "react"; + +export type CardSize = 1 | 2 | 3 | 4 | 5; + +// The card's size, shared with CardInset so it can negate the matching padding. +const CardContext = createContext(1); + +export const CardProvider = CardContext.Provider; + +export function useCardContext() { + return useContext(CardContext); +} diff --git a/packages/ui/src/v2/Card/variants.ts b/packages/ui/src/v2/Card/variants.ts new file mode 100644 index 000000000..27e57d812 --- /dev/null +++ b/packages/ui/src/v2/Card/variants.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +// Container that groups related content (Radix "Card"). The surface treatment +// resolves in the compound variants below. +export const card = tv({ + base: "block overflow-hidden", + variants: { + // Padding scales with size; radius scales with it too (per the Figma). + size: { + 1: "rounded-4 p-3", + 2: "rounded-4 p-4", + 3: "rounded-5 p-5", + 4: "rounded-5 p-6", + 5: "rounded-6 p-8", + }, + variant: { + surface: "border border-sand-6 bg-sand-2", + classic: "border border-sand-6 bg-sand-2 shadow-2", + ghost: "", + }, + // Hover/active affordance for clickable cards. This tunes look only; wrap + // the card in an /router link (or Base UI `render`) for real navigation. + interactive: { + true: "cursor-pointer transition-colors", + false: "", + }, + }, + compoundVariants: [ + { variant: ["surface", "classic"], interactive: true, class: "hover:bg-sand-3 active:bg-sand-4" }, + { variant: "ghost", interactive: true, class: "hover:bg-sand-3 active:bg-sand-4" }, + ], + defaultVariants: { + size: 1, + variant: "surface", + interactive: false, + }, +}); + +// Negates the card's padding so content bleeds to its edges. The negative +// margin must match the card's size padding (1→3, 2→4, 3→5, 4→6, 5→8), so the +// matrix is keyed on both size and side. `overflow-hidden` clips bled media. +export const cardInset = tv({ + base: "overflow-hidden", + variants: { + size: { 1: "", 2: "", 3: "", 4: "", 5: "" }, + side: { all: "", x: "", y: "", top: "", bottom: "" }, + }, + compoundVariants: [ + { size: 1, side: "all", class: "-m-3" }, + { size: 1, side: "x", class: "-mx-3" }, + { size: 1, side: "y", class: "-my-3" }, + { size: 1, side: "top", class: "-mx-3 -mt-3 mb-3" }, + { size: 1, side: "bottom", class: "-mx-3 -mb-3 mt-3" }, + { size: 2, side: "all", class: "-m-4" }, + { size: 2, side: "x", class: "-mx-4" }, + { size: 2, side: "y", class: "-my-4" }, + { size: 2, side: "top", class: "-mx-4 -mt-4 mb-4" }, + { size: 2, side: "bottom", class: "-mx-4 -mb-4 mt-4" }, + { size: 3, side: "all", class: "-m-5" }, + { size: 3, side: "x", class: "-mx-5" }, + { size: 3, side: "y", class: "-my-5" }, + { size: 3, side: "top", class: "-mx-5 -mt-5 mb-5" }, + { size: 3, side: "bottom", class: "-mx-5 -mb-5 mt-5" }, + { size: 4, side: "all", class: "-m-6" }, + { size: 4, side: "x", class: "-mx-6" }, + { size: 4, side: "y", class: "-my-6" }, + { size: 4, side: "top", class: "-mx-6 -mt-6 mb-6" }, + { size: 4, side: "bottom", class: "-mx-6 -mb-6 mt-6" }, + { size: 5, side: "all", class: "-m-8" }, + { size: 5, side: "x", class: "-mx-8" }, + { size: 5, side: "y", class: "-my-8" }, + { size: 5, side: "top", class: "-mx-8 -mt-8 mb-8" }, + { size: 5, side: "bottom", class: "-mx-8 -mb-8 mt-8" }, + ], + defaultVariants: { + size: 1, + side: "all", + }, +}); + +export const cardSkeleton = tv({ + base: "block w-full animate-pulse rounded-4 bg-sand-3", + variants: { + size: { + 1: "h-20", + 2: "h-24", + 3: "h-28", + 4: "h-32", + 5: "h-40", + }, + }, + defaultVariants: { + size: 1, + }, +});