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
184 changes: 170 additions & 14 deletions animata/card/card-spread.css
Original file line number Diff line number Diff line change
@@ -1,31 +1,187 @@
@reference "../../styles/globals.css";

@layer components {
.card-spread-stage {
/* Quick throw, soft settle — expand on click only */
.card-spread {
--ease-throw: cubic-bezier(0.22, 1.18, 0.36, 1);
--ease-gather: cubic-bezier(0.33, 1, 0.68, 1);
}

.card-spread-stage {
/*
* overflow-x: auto forces overflow-y: auto — pad vertically so spread
* tilt and hover peek are not clipped.
*/
@apply w-full px-4 py-8;
}

/* Deck: center the wide grid, clip sides — stack sits at grid center (col 1.5). */
.card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-stage {
@apply flex items-end justify-center overflow-x-hidden;
}

/* Spread: horizontal scroll when needed; center the row when it fits. */
.card-spread:has(.card-spread-toggle:checked) .card-spread-stage {
@apply overflow-x-auto overscroll-x-contain;
}

@media (min-width: 64rem) {
.card-spread:has(.card-spread-toggle:checked) .card-spread-stage {
@apply flex items-end justify-center;
}
}

.card-spread-container {
--card-gap: 0.75rem;
/* 4× w-48 + 3× gap-3 — never shrink or deck offsets miss grid tracks */
@apply grid w-fit min-w-[calc(4*12rem+3*0.75rem)] shrink-0 grid-cols-4 gap-3;
}

.card-spread-toggle {
@apply sr-only;
}

.card-spread-motion--expand {
.card-spread-open {
@apply relative inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors select-none;
}

.card-spread-open:hover {
@apply bg-accent/10;
}

.card-spread-open:has(.card-spread-toggle:focus-visible) {
@apply outline-2 outline-[#ffcc00] outline-offset-2;
}

.card-spread-open__text--collapse {
@apply hidden;
}

.card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--expand {
@apply hidden;
}

.card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--collapse {
@apply inline;
}

.card-spread-item {
@apply origin-bottom;
}

.card-spread-item:nth-child(1) {
@apply z-10;
}

.card-spread-item:nth-child(2) {
@apply z-20;
}

.card-spread-item:nth-child(3) {
@apply z-30;
}

.card-spread-item:nth-child(4) {
@apply z-40;
}

.card-spread-item__internal {
@apply origin-bottom motion-reduce:transition-none;

transition: transform 520ms var(--ease-gather);
}
Comment thread
sudhashrestha marked this conversation as resolved.

.card-spread:has(.card-spread-toggle:checked) .card-spread-item__internal {
transition: transform 480ms var(--ease-throw);
}

.card-spread-motion--collapse {
transition: transform 520ms cubic-bezier(0.33, 1, 0.68, 1);
/*
* Deck — 1×4 grid, pull each card toward col 1.5 via transform.
* Same explicit calc() on all breakpoints so spread animates on mobile too.
*/
.card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item:nth-child(1)
.card-spread-item__internal {
transform: translate3d(calc(150% + 1.125rem), 0, 0);
}

.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-item:nth-child(2)
.card-spread-item__internal {
transform: translate3d(calc(50% + 0.375rem), 0, 0);
}

.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-item:nth-child(3)
.card-spread-item__internal {
transform: translate3d(calc(-50% - 0.375rem), 0, 0);
}

.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-item:nth-child(4)
.card-spread-item__internal {
transform: translate3d(calc(-150% - 1.125rem), 0, 0);
}

/* Spread — layout slots, light rotation */
.card-spread:has(.card-spread-toggle:checked) .card-spread-item:nth-child(1)
.card-spread-item__internal {
transform: translate3d(0, 0, 0) rotate(-2deg);
}

.card-spread:has(.card-spread-toggle:checked)
.card-spread-item:nth-child(2)
.card-spread-item__internal {
transform: translate3d(0, 0.5rem, 0) rotate(3deg);
}

.card-spread:has(.card-spread-toggle:checked)
.card-spread-item:nth-child(3)
.card-spread-item__internal {
transform: translate3d(0.25rem, 0, 0) rotate(-2deg);
}

.card-spread:has(.card-spread-toggle:checked)
.card-spread-item:nth-child(4)
.card-spread-item__internal {
transform: translate3d(0, 0, 0) rotate(2deg);
}

/* Hover peek — deck only */
.card-spread-item__hover {
@apply origin-bottom transition-transform duration-300 ease-out motion-reduce:transition-none;
}

.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-stage:hover
.card-spread-item:nth-child(1)
.card-spread-item__hover {
@apply -rotate-1;
}

.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-stage:hover
.card-spread-item:nth-child(2)
.card-spread-item__hover {
@apply -rotate-2;
}

.card-spread-stage--expand {
transition: width 480ms var(--ease-throw);
.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-stage:hover
.card-spread-item:nth-child(3)
.card-spread-item__hover {
@apply rotate-1;
}

.card-spread-stage--collapse {
transition: width 520ms cubic-bezier(0.33, 1, 0.68, 1);
.card-spread:not(:has(.card-spread-toggle:checked))
.card-spread-stage:hover
.card-spread-item:nth-child(4)
.card-spread-item__hover {
@apply rotate-2;
}

@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;
.card-spread-item__internal,
.card-spread-item__hover {
@apply !duration-[0.01ms];
}
}
}
115 changes: 26 additions & 89 deletions animata/card/card-spread.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
"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";

import "./card-spread.css";

/** 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
Expand All @@ -40,88 +27,38 @@ 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,
spreadTransform: `translateX(0) rotate(-2deg)`,
},
{
component: ShoppingList,
spreadTransform: `translateX(${CARD_STEP}) translateY(0.5rem) rotate(3deg)`,
},
{
component: RemodelNotes,
spreadTransform: `translateX(calc(2 * ${CARD_STEP})) rotate(-2deg) translateX(0.25rem)`,
},
{
component: Reminders,
spreadTransform: `translateX(calc(3 * ${CARD_STEP})) rotate(2deg)`,
},
{ id: "notes", component: Notes },
{ id: "shopping-list", component: ShoppingList },
{ id: "remodel-notes", component: RemodelNotes },
{ id: "reminders", component: Reminders },
] 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="inline-flex min-h-80 items-end justify-center overflow-visible p-8">
<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 className="card-spread flex min-h-96 w-full flex-col items-center justify-center gap-4">
<label className="card-spread-open">
<input type="checkbox" className="card-spread-toggle" />
<span className="card-spread-open__text card-spread-open__text--expand">Spread cards</span>
<span className="card-spread-open__text card-spread-open__text--collapse">Stack cards</span>
</label>

<div className="card-spread-stage group/stack">
<div className="card-spread-container">
{cards.map((item) => {
const Card = item.component;

return (
<div key={item.id} className="card-spread-item w-48 shrink-0">
<div className="card-spread-item__hover">
<div className="card-spread-item__internal">
<Card />
</div>
</div>
</div>
</div>
);
})}
);
})}
Comment thread
sudhashrestha marked this conversation as resolved.
</div>
</div>
</div>
);
Expand Down
28 changes: 19 additions & 9 deletions content/docs/card/card-spread.mdx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
---
title: Card Spread
description: A stacked deck of widget cards with a subtle hover peek and a click-to-spread throw animation.
description: A stacked deck of widget cards with a hover peek and a throw-on-spread animation — layout-first, CSS-only.
author: sudhashrestha
labels: ["requires interaction", "click", "hover"]
---

<ComponentPreview name="card-card-spread--primary" />

Four related cards share one compact stack until someone asks to see more. Hover the deck and the tiles fan slightly; hit **Spread cards** and they throw into a row. Good for dashboards, widget galleries, or anywhere you want several `w-48` tiles without eating horizontal space up front.

The demo wires up Notes, Shopping List, and a couple of note variants. Swap in your own card components as long as they keep the same footprint.

## Installation

### CLI
Expand All @@ -18,7 +22,7 @@ labels: ["requires interaction", "click", "hover"]
<Steps>
<Step>Run the following command</Step>

It will create `card-spread.tsx` and `card-spread.css` inside the `components/animata/card` directory.
This creates `card-spread.tsx` and `card-spread.css` in `components/animata/card`.

```bash
mkdir -p components/animata/card && touch components/animata/card/card-spread.tsx components/animata/card/card-spread.css
Expand All @@ -36,17 +40,23 @@ mkdir -p components/animata/card && touch components/animata/card/card-spread.ts

</Steps>

## Usage
## How it works

The cards always sit in a four-column row at `w-48` each. On narrow viewports the stage scrolls horizontally instead of reflowing into fewer columns. That's their real spread position. In the deck state, each card is shifted toward the center of that row so they pile on top of each other — outer cards travel farther, so the throw opens from the middle.

Click **Spread cards** and those shifts animate back to zero. The cards land in the grid slots they already occupy. Spread uses a quick throw with a slight overshoot (`--ease-throw`); stacking back up uses a gentler ease-out (`--ease-gather`). Only `transform` is animated — nothing about the container width changes, which keeps the motion smooth across browsers.

Hover is separate. While the deck is stacked, moving over the stage gives each card a small rotate nudge on an outer wrapper. That peek does not run once the row is spread, so it never competes with the throw.

State is a checkbox behind the toggle label — no React state, no click handler on the cards themselves. When spread, the tiles underneath stay interactive (checkboxes, links, and so on).

The stack has three states:
## Customization

1. **Deck** — all cards share one origin (transform-only, no absolute fan layout).
2. **Hover** — a light vertical peek on the outer layer; spread motion is unchanged.
3. **Click** — cards throw into their spread positions with a custom `--ease-throw` curve; collapse uses a separate ease-out gather.
Swap the card components in the `cards` array. Keep them at `w-48` unless you also update the grid gap and the per-card `translate3d(calc(...))` deck offsets in the CSS. The stage uses `overflow-x-auto` — adjust padding or scroll-snap there if you want a different narrow-screen feel.

Toggle with click or <kbd>Enter</kbd> / <kbd>Space</kbd> when the stage is focused. The demo composes Notes, Shopping List, and related widget cards — swap in your own `w-48` tiles as needed.
To change the throw character, edit `--ease-throw` and `--ease-gather` in `card-spread.css`. Per-card spread tilt and deck hover peek are set per `:nth-child` in the same file.

Motion is CSS-only: an inner wrapper handles spread/collapse; an outer wrapper handles hover peek so the two never fight for `transform`.
Adding or removing cards means updating both the array and the matching CSS rules for deck offset, z-index, and rotation.

## Credits

Expand Down
Loading