Add v2 Card component

Implement the Card component from the Probo Radix UI Figma: a <div>
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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-25 00:04:24 +02:00
parent 1992421c91
commit 87039f8142
6 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<div className="flex flex-col gap-1">
<span className="text-2 font-medium text-sand-12">Teodros Girmay</span>
<span className="text-2 text-sand-11">Engineering</span>
</div>
);
}
export default {
title: "v2/Card",
component: Card,
args: {
size: 1,
variant: "surface",
interactive: false,
},
render: args => (
<div className="w-72">
<Card {...args}><Sample /></Card>
</div>
),
} satisfies Meta<typeof Card>;
type Story = StoryObj<typeof Card>;
export const Playground: Story = {};
export const Sizes: Story = {
render: () => (
<div className="flex w-72 flex-col gap-4">
{sizes.map(size => (
<Card key={size} size={size}><Sample /></Card>
))}
</div>
),
};
export const Variants: Story = {
render: () => (
<div className="flex w-72 flex-col gap-4">
{variants.map(variant => (
<Card key={variant} variant={variant}><Sample /></Card>
))}
</div>
),
};
export const Interactive: Story = {
render: () => (
<div className="flex w-72 flex-col gap-4">
<Card interactive><Sample /></Card>
<Card variant="ghost" interactive><Sample /></Card>
</div>
),
};
export const Inset: Story = {
render: () => (
<div className="w-72">
<Card size={2}>
<CardInset side="top">
<img
src="https://images.unsplash.com/photo-1517433670267-08bbd4be890f?w=600"
alt=""
className="h-32 w-full object-cover"
/>
</CardInset>
<Sample />
</Card>
</div>
),
};
export const Skeleton: Story = {
render: () => (
<div className="w-72">
<CardSkeleton size={2} />
</div>
),
};

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<typeof card>;
// Container that groups related content (Radix "Card"). Renders a <div>; 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 (
<div className={card({ size, variant, interactive, className })} {...rest}>
<CardProvider value={size}>{children}</CardProvider>
</div>
);
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 <div className={cardInset({ size, side, className })} {...rest} />;
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ComponentProps<"div">, "children"> & VariantProps<typeof cardSkeleton>;
// Loading placeholder paired with Card: a pulse block matching its size.
export function CardSkeleton(props: CardSkeletonProps) {
const { size, className, ...rest } = props;
return <div aria-hidden className={cardSkeleton({ size, className })} {...rest} />;
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<CardSize>(1);
export const CardProvider = CardContext.Provider;
export function useCardContext() {
return useContext(CardContext);
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 <a>/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,
},
});