Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 111 additions & 46 deletions animata/card/card-spread.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
"use client";

import { useState } from "react";

import Notes, { NotesCard } from "@/animata/widget/notes";
import ShoppingList from "@/animata/widget/shopping-list";
import { cn } from "@/lib/utils";

/** w-48 + gap-3 — horizontal step between spread slots */
const CARD_STEP = "12.75rem";

/** 4× w-48 + 3× gap-3 */
const SPREAD_WIDTH = "calc(4 * 12rem + 3 * 0.75rem)";

const DECK_TRANSFORM = "translateX(0) rotate(0deg)";

function Reminders() {
return (
<ShoppingList
title="Reminders"
data={[
{ title: "book museum tickets" },
{ title: "buy groceries", checked: true },
{ title: "call mom" },
items={[
{ id: "museum", title: "Book museum tickets" },
{ id: "groceries", title: "Buy groceries", checked: true },
{ id: "mom", title: "Call mom" },
]}
/>
);
Expand All @@ -28,66 +38,121 @@ function RemodelNotes() {
);
}

/** Outer layer: hover peek only (ease-out, not ease-cc). */
const hoverPeekClasses = [
"group-hover/stack:-translate-y-1 group-hover/stack:-rotate-[1deg]",
"group-hover/stack:-translate-y-2 group-hover/stack:rotate-[2deg]",
"group-hover/stack:-translate-y-3 group-hover/stack:rotate-[3deg]",
"group-hover/stack:-translate-y-4 group-hover/stack:rotate-[4deg]",
] as const;

const cards = [
{
component: Notes,
rotationClass: "",
revealClass: "-rotate-[2deg]",
spreadTransform: `translateX(0) rotate(-2deg)`,
},
{
component: ShoppingList,
rotationClass: "group-hover/spread:rotate-[15deg]",
revealClass: "rotate-[3deg] translate-y-2",
spreadTransform: `translateX(${CARD_STEP}) translateY(0.5rem) rotate(3deg)`,
},

{
component: RemodelNotes,
rotationClass: "group-hover/spread:rotate-[30deg]",
revealClass: "-rotate-[2deg] translate-x-1",
spreadTransform: `translateX(calc(2 * ${CARD_STEP})) rotate(-2deg) translateX(0.25rem)`,
},

{
component: Reminders,
rotationClass: "group-hover/spread:rotate-[45deg]",
revealClass: "rotate-[2deg]",
spreadTransform: `translateX(calc(3 * ${CARD_STEP})) rotate(2deg)`,
},
];
] as const;

const zIndex = ["z-10", "z-20", "z-30", "z-40"] as const;

export default function CardSpread() {
const [isExpanded, setExpanded] = useState(false);

return (
<div
className={cn(
"group/spread relative flex min-h-80 min-w-52 items-center transition-all duration-500 ease-in-out",
{
"origin-bottom hover:-rotate-[15deg]": !isExpanded,
"gap-3": isExpanded,
},
)}
>
{cards.map((item, index) => {
return (
<div
key={index}
onClick={(e) => {
setExpanded(!isExpanded);
e.preventDefault();
}}
className={cn(
"transition-all duration-500 ease-in-out",
{
absolute: !isExpanded,
"origin-bottom": !isExpanded,
},
!isExpanded && item.rotationClass,
isExpanded && item.revealClass,
)}
>
<item.component />
</div>
);
})}
<div className="inline-flex min-h-80 items-end justify-center overflow-visible p-8">
<style>{`
.card-spread-stage {
/* Quick throw, soft settle — expand on click only */
--ease-throw: cubic-bezier(0.22, 1.18, 0.36, 1);
}

.card-spread-motion--expand {
transition: transform 480ms var(--ease-throw);
}

.card-spread-motion--collapse {
transition: transform 520ms cubic-bezier(0.33, 1, 0.68, 1);
}

.card-spread-stage--expand {
transition: width 480ms var(--ease-throw);
}

.card-spread-stage--collapse {
transition: width 520ms cubic-bezier(0.33, 1, 0.68, 1);
}

@media (prefers-reduced-motion: reduce) {
.card-spread-motion--expand,
.card-spread-motion--collapse,
.card-spread-stage--expand,
.card-spread-stage--collapse {
transition-duration: 0.01ms !important;
}
}
`}</style>

<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
aria-label={isExpanded ? "Collapse card spread" : "Expand card spread"}
onClick={() => setExpanded((open) => !open)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((open) => !open);
}
}}
className={cn(
"card-spread-stage group/stack relative h-64 outline-none focus-visible:ring-2 focus-visible:ring-[#ffcc00] focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isExpanded
? "card-spread-stage--expand cursor-default"
: "card-spread-stage--collapse w-48 cursor-pointer",
)}
style={isExpanded ? { width: SPREAD_WIDTH } : undefined}
>
{cards.map((item, index) => {
const Card = item.component;
return (
<div
key={index}
className={cn(
"absolute bottom-0 left-0 origin-bottom",
zIndex[index],
!isExpanded && [
"transition-transform duration-300 ease-out motion-reduce:transition-none",
hoverPeekClasses[index],
],
)}
>
<div
className={cn(
"origin-bottom motion-reduce:transition-none motion-reduce:transform-none",
isExpanded ? "card-spread-motion--expand" : "card-spread-motion--collapse",
)}
style={{
transform: isExpanded ? item.spreadTransform : DECK_TRANSFORM,
}}
>
<Card />
</div>
</div>
);
})}
</div>
</div>
);
}
56 changes: 56 additions & 0 deletions animata/card/collab-card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react";

import CollabCard from "@/animata/card/collab-card";

const meta = {
title: "Card/Collab Card",
component: CollabCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
greeting: { control: "text" },
eyebrow: { control: "text" },
intro: { control: "text" },
conjunction: { control: "text" },
trailing: { control: "text" },
liveLabel: { control: "text" },
backgroundUrl: { control: "text" },
},
} satisfies Meta<typeof CollabCard>;

export default meta;
type Story = StoryObj<typeof meta>;

/** Figma multiplayer — dashed frame, live presence, named cursor pills. */
export const Primary: Story = {
args: {
greeting: "hello!",
eyebrow: "Now in multiplayer",
intro: "editing",
conjunction: "&",
trailing: "",
liveLabel: "Live · 4 editing",
},
render: (args) => (
<div className="w-full full-content">
<CollabCard {...args} className="rounded-xl" />
</div>
),
};

/** Bento slot — scales via container queries, not viewport breakpoints. */
export const InBentoGrid: Story = {
render: () => (
<div className="grid max-w-3xl grid-cols-3 gap-4">
<div className="col-span-2">
<CollabCard />
</div>
<div className="flex min-h-40 items-center rounded-2xl border border-border bg-muted/50 p-5 text-sm leading-relaxed text-muted-foreground">
Drop into a wide bento cell — frame, greeting, pills, and cursors track the card width via{" "}
<code className="text-xs">cqi</code>.
</div>
</div>
),
};
Loading