From 2d1039be71394a28c7197e0a3721881d642592b3 Mon Sep 17 00:00:00 2001 From: Sudha Shrestha Date: Fri, 12 Jun 2026 10:56:03 +0545 Subject: [PATCH 1/3] refactor(card-spread): layout-first CSS-only deck and spread Drop React state and width animation for a checkbox-driven grid. Stack offsets use transform; deck centers on large screens; narrow viewports scroll horizontally. Fixes Safari jitter from width tween. Co-authored-by: Cursor --- animata/card/card-spread.css | 207 +++++++++++++++++++++++++++-- animata/card/card-spread.tsx | 115 ++++------------ content/docs/card/card-spread.mdx | 28 ++-- content/docs/changelog/2026-06.mdx | 2 +- 4 files changed, 239 insertions(+), 113 deletions(-) diff --git a/animata/card/card-spread.css b/animata/card/card-spread.css index 5335bbf4..cbbcbc9b 100644 --- a/animata/card/card-spread.css +++ b/animata/card/card-spread.css @@ -1,31 +1,210 @@ +@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; + @apply grid w-fit shrink-0 grid-cols-4 gap-3; + } + + .card-spread-toggle { + @apply sr-only; + } + + .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-motion--expand { + .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); + } + + .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); + /* + * Phone / tablet — single w-48 pile (no % offsets; nested calc was invalid in WebKit). + */ + @media (max-width: 63.999rem) { + .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-container { + @apply relative block h-64 w-48; + } + + .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item { + @apply absolute bottom-0 left-0 w-48; + } + + .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item__internal { + transform: none; + } + + .card-spread:has(.card-spread-toggle:checked) .card-spread-container { + @apply relative grid h-full w-fit grid-cols-4; + } + + .card-spread:has(.card-spread-toggle:checked) .card-spread-item { + @apply static w-48; + } + } + + /* Desktop — 1×4 row, pull each card toward col 1.5 */ + @media (min-width: 64rem) { + .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]; } } } diff --git a/animata/card/card-spread.tsx b/animata/card/card-spread.tsx index 216571bc..0920b650 100644 --- a/animata/card/card-spread.tsx +++ b/animata/card/card-spread.tsx @@ -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 ( -
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 ( -
-
- +
+ + +
+
+ {cards.map((item) => { + const Card = item.component; + + return ( +
+
+
+ +
+
-
- ); - })} + ); + })} +
); diff --git a/content/docs/card/card-spread.mdx b/content/docs/card/card-spread.mdx index ceb2c6a9..c700d6f0 100644 --- a/content/docs/card/card-spread.mdx +++ b/content/docs/card/card-spread.mdx @@ -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"] --- +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 @@ -18,7 +22,7 @@ labels: ["requires interaction", "click", "hover"] Run the following command -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 @@ -36,17 +40,23 @@ mkdir -p components/animata/card && touch components/animata/card/card-spread.ts -## 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 `--deck-step` 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 Enter / Space 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 diff --git a/content/docs/changelog/2026-06.mdx b/content/docs/changelog/2026-06.mdx index 61903f75..45650425 100644 --- a/content/docs/changelog/2026-06.mdx +++ b/content/docs/changelog/2026-06.mdx @@ -72,7 +72,7 @@ Added Stacked Sections for pinned chapter scroll stories. Wave 1 widgets got App

Card Spread

-

Stacked deck with a subtle hover peek and click-to-spread throw easing. Use when you want several related cards in one compact stack that expands on demand.

+

Stacked deck with a subtle hover peek and click-to-spread throw easing. Spread positions come from a four-column grid with stack offsets applied via transform; fixed a Safari jitter bug caused by animating stage width alongside card transforms.

Collab Card

From 35dfc910105028a33404372a8fce5435848d18e4 Mon Sep 17 00:00:00 2001 From: Sudha Shrestha Date: Fri, 12 Jun 2026 13:51:12 +0545 Subject: [PATCH 2/3] fix(card-spread): animate mobile throw with unified grid transforms Use deck calc() offsets on all breakpoints instead of absolute-to-grid snap. Add grid min-width so narrow containers keep correct stack math. Co-authored-by: Cursor --- animata/card/card-spread.css | 66 ++++++++++-------------------- content/docs/card/card-spread.mdx | 2 +- content/docs/changelog/2026-06.mdx | 2 +- 3 files changed, 23 insertions(+), 47 deletions(-) diff --git a/animata/card/card-spread.css b/animata/card/card-spread.css index cbbcbc9b..5d895ebd 100644 --- a/animata/card/card-spread.css +++ b/animata/card/card-spread.css @@ -32,7 +32,8 @@ .card-spread-container { --card-gap: 0.75rem; - @apply grid w-fit shrink-0 grid-cols-4 gap-3; + /* 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 { @@ -93,55 +94,30 @@ } /* - * Phone / tablet — single w-48 pile (no % offsets; nested calc was invalid in WebKit). + * 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. */ - @media (max-width: 63.999rem) { - .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-container { - @apply relative block h-64 w-48; - } - - .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item { - @apply absolute bottom-0 left-0 w-48; - } - - .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item__internal { - transform: none; - } - - .card-spread:has(.card-spread-toggle:checked) .card-spread-container { - @apply relative grid h-full w-fit grid-cols-4; - } - - .card-spread:has(.card-spread-toggle:checked) .card-spread-item { - @apply static w-48; - } + .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); } - /* Desktop — 1×4 row, pull each card toward col 1.5 */ - @media (min-width: 64rem) { - .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(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(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); - } + .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 */ diff --git a/content/docs/card/card-spread.mdx b/content/docs/card/card-spread.mdx index c700d6f0..74c2413e 100644 --- a/content/docs/card/card-spread.mdx +++ b/content/docs/card/card-spread.mdx @@ -52,7 +52,7 @@ State is a checkbox behind the toggle label — no React state, no click handler ## Customization -Swap the card components in the `cards` array. Keep them at `w-48` unless you also update the grid gap and `--deck-step` in the CSS. The stage uses `overflow-x-auto` — adjust padding or scroll-snap there if you want a different narrow-screen feel. +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. 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. diff --git a/content/docs/changelog/2026-06.mdx b/content/docs/changelog/2026-06.mdx index 45650425..1c4034b3 100644 --- a/content/docs/changelog/2026-06.mdx +++ b/content/docs/changelog/2026-06.mdx @@ -72,7 +72,7 @@ Added Stacked Sections for pinned chapter scroll stories. Wave 1 widgets got App

Card Spread

-

Stacked deck with a subtle hover peek and click-to-spread throw easing. Spread positions come from a four-column grid with stack offsets applied via transform; fixed a Safari jitter bug caused by animating stage width alongside card transforms.

+

Stacked deck with a subtle hover peek and click-to-spread throw easing. Spread positions come from a four-column grid with stack offsets applied via transform on every breakpoint; fixed a Safari jitter bug caused by animating stage width alongside card transforms.

Collab Card

From 1e11334e4f128b67dc74eb950d89fd1a0c8af0cd Mon Sep 17 00:00:00 2001 From: Sudha Shrestha Date: Fri, 12 Jun 2026 14:08:07 +0545 Subject: [PATCH 3/3] fix(card-spread): address stylelint spacing and stable list keys Add blank line between @apply and transition in deck motion CSS. Use explicit card ids for React keys instead of minified names. Co-authored-by: Cursor --- animata/card/card-spread.css | 1 + animata/card/card-spread.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/animata/card/card-spread.css b/animata/card/card-spread.css index 5d895ebd..d374079e 100644 --- a/animata/card/card-spread.css +++ b/animata/card/card-spread.css @@ -86,6 +86,7 @@ .card-spread-item__internal { @apply origin-bottom motion-reduce:transition-none; + transition: transform 520ms var(--ease-gather); } diff --git a/animata/card/card-spread.tsx b/animata/card/card-spread.tsx index 0920b650..9e32c811 100644 --- a/animata/card/card-spread.tsx +++ b/animata/card/card-spread.tsx @@ -28,10 +28,10 @@ function RemodelNotes() { } const cards = [ - { component: Notes }, - { component: ShoppingList }, - { component: RemodelNotes }, - { component: Reminders }, + { id: "notes", component: Notes }, + { id: "shopping-list", component: ShoppingList }, + { id: "remodel-notes", component: RemodelNotes }, + { id: "reminders", component: Reminders }, ] as const; export default function CardSpread() { @@ -49,7 +49,7 @@ export default function CardSpread() { const Card = item.component; return ( -
+