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:
104
packages/ui/src/v2/Card/Card.stories.tsx
Normal file
104
packages/ui/src/v2/Card/Card.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
34
packages/ui/src/v2/Card/Card.tsx
Normal file
34
packages/ui/src/v2/Card/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
packages/ui/src/v2/Card/CardInset.tsx
Normal file
33
packages/ui/src/v2/Card/CardInset.tsx
Normal 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} />;
|
||||
}
|
||||
27
packages/ui/src/v2/Card/CardSkeleton.tsx
Normal file
27
packages/ui/src/v2/Card/CardSkeleton.tsx
Normal 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} />;
|
||||
}
|
||||
26
packages/ui/src/v2/Card/context.ts
Normal file
26
packages/ui/src/v2/Card/context.ts
Normal 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);
|
||||
}
|
||||
109
packages/ui/src/v2/Card/variants.ts
Normal file
109
packages/ui/src/v2/Card/variants.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user