Expandable Card
Free · MITCard that expands into a focused overlay on desktop and a draggable bottom sheet on mobile, with three snap points. Portal-rendered, focus-trapped, scroll-locked and reduced-motion aware.
- Category
- Cards & Expandables
- Framework
- react
- Version
- 1.0.1
- Peer deps
- react, react-dom
Install
velocityx add expandable-card
Free and MIT licensed — no account needed. The file lands under src/components/velocity/expandable-card/ by default (override with --dest), and it's yours to edit from the moment it's written.
Demo
View the live demo on the showcase site ↗
Source
Pulled live from the registry — this is the exact file velocityx add expandable-card writes into your project right now.
components/velocity/expandable-card/ExpandableCard.jsx
/*
* VELOCITY COMPONENT · theming
*
* Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
* the Velocity preset. Out of the box they are a neutral indigo — NOT the
* Aidxn brand — so this drops into your project without dragging our look in.
*
* ⚡ NOT USING VELOCITY? Run this once, in your project root:
*
* velocityx theme
*
* It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
* tailwind.config, import the css, then set your whole brand in ONE place:
*
* :root {
* --vx-brand: custom;
* --vx-primary-600: 220 38 38; <- your action colour
* --vx-primary-700: 185 28 28;
* }
*
* ⚠️ Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
* breaks every /opacity variant in the library (bg-primary-600/20 etc).
*
* Prefer not to install anything? Find-and-replace `primary-` with your own
* scale name. Nothing else in this file depends on Velocity.
*/
/**
* ExpandableCard — Stripe-style collapsed-tile + expandable-sheet pattern.
*
* Collapsed: rounded card with eyebrow, title, corner "indent" expand
* button (diagonal arrows-out icon), and a gradient-bg preview slot
* hosting a mini-illustration of the feature.
*
* Expanded: portal-rendered sheet (bottom on mobile, right drawer on
* desktop) with drag-to-close handle, X close button, scrollable area
* containing title + description + optional CTAs + optional checked
* bullet list + a larger version of the preview content.
*
* Props (single component owns both states):
* eyebrow string e.g. "CRM — LEAD MAP"
* title string collapsed title
* expandedTitle? string optional override in sheet
* description string | ReactNode paragraph in sheet
* preview ReactNode mini-illustration slot
* previewLarge? ReactNode larger version in sheet (defaults to preview)
* previewGradient string Tailwind gradient classes for preview wrapper
* ctaPrimary? { label, href }
* ctaSecondary? { label, href }
* bullets? string[]
* compact? boolean phone 3-col grid mode
* className? string extra classes on outer card
*/
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import useSheetDrag from './useSheetDrag.js';
// Top-right corner notch — a radial-gradient mask cuts a clean concave scoop
// out of the card's actual TOP-RIGHT corner (transparent, so the corner really
// reads as cut away, revealing whatever's behind — works over any bg). The
// expand button sits centred in the scoop with an even gap. Notch centre 26px
// in from the top-right corner; button (36px) at 8px inset (top-2 right-2) →
// its centre lands at 26px, concentric with the notch.
const CARD_NOTCH = 'radial-gradient(circle 25px at calc(100% - 26px) 26px, transparent 24px, #000 25px)';
/* ----------------------- Corner Expand / Close Button ---------------------- */
function CornerButton({ open, onClick, size = 'md' }) {
const dim = size === 'sm' ? 'w-7 h-7' : 'w-9 h-9';
const icon = open ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
) : (
// Diagonal arrows pointing outward from center — the "expand" glyph.
<svg width={size === 'sm' ? 11 : 13} height={size === 'sm' ? 11 : 13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" />
<path d="M9 21H3v-6" />
<path d="m21 3-7 7" />
<path d="m3 21 7-7" />
</svg>
);
return (
<button
type="button"
onClick={onClick}
aria-label={open ? 'Close' : 'Expand'}
className={`${dim} shrink-0 inline-flex items-center justify-center rounded-xl bg-primary-50 dark:bg-primary-500/15 text-primary-600 dark:text-primary-300 transition-all hover:bg-primary-100 dark:hover:bg-primary-500/25`}
style={{
boxShadow: 'inset 0 0 0 1.5px color-mix(in srgb, var(--primary-500) 55%, transparent)',
}}
>
{icon}
</button>
);
}
/* ------------------------------- The Sheet -------------------------------- */
function ExpandableSheet({
open,
onClose,
title,
description,
previewLarge,
previewGradient,
ctaPrimary,
ctaSecondary,
bullets,
links,
}) {
const { dragOffset, dragHandlers, isLive } = useSheetDrag(onClose);
const [isDesktop, setIsDesktop] = useState(false);
// Defer the portal until after first client render. Without this we
// hydrate to a different tree than the server produced (server: null,
// client: portal-to-body), which makes React tear down and rebuild
// the surrounding subtree — that cascade was unmounting/remounting
// RadialGallery and spiking the shader re-init on every page load.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const mq = window.matchMedia('(min-width: 1024px)');
const update = () => setIsDesktop(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
const transform = open
? isDesktop
? `translateX(${dragOffset}px)`
: `translateY(${dragOffset}px)`
: isDesktop
? 'translateX(100%)'
: 'translateY(100%)';
const sheetClass = isDesktop
? 'fixed top-0 right-0 bottom-0 w-full max-w-[480px] bg-white dark:bg-secondary-900 shadow-[-12px_0_40px_rgba(0,0,0,0.25)] flex flex-col'
: 'fixed inset-x-0 bottom-0 bg-white dark:bg-secondary-900 rounded-t-2xl shadow-[0_-12px_40px_rgba(0,0,0,0.25)] flex flex-col max-h-[88vh]';
if (!mounted || typeof document === 'undefined') return null;
return createPortal(
<div
aria-hidden={!open}
className={`fixed inset-0 z-[60] transition-opacity duration-200 ${
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<div
className="absolute inset-0 bg-black/40 dark:bg-black/65"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
className={sheetClass}
style={{
transform,
transition: isLive ? 'none' : 'transform 300ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
{/* Drag handle (mobile only — also serves as close affordance on desktop) */}
{!isDesktop && (
<div
className="shrink-0 pt-3 pb-2 flex justify-center cursor-grab active:cursor-grabbing touch-none"
{...dragHandlers}
>
<div className="h-1.5 w-10 rounded-full bg-primary-300 dark:bg-primary-700" />
</div>
)}
{/* Header: title + close X (desktop has extra top padding to balance drag handle absence) */}
<div className={`shrink-0 flex items-start justify-between gap-4 px-5 sm:px-7 ${isDesktop ? 'pt-7' : 'pt-1'} pb-4`}>
<h2 className="v-card-title font-semibold text-primary-950 dark:text-white pr-2">
{title}
</h2>
<CornerButton open onClick={onClose} size="md" />
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overscroll-contain px-5 sm:px-7 pb-7">
{description && (
<div className="space-y-3.5 v-card-body text-primary-700 dark:text-primary-200/80 [&_strong]:font-semibold [&_strong]:text-primary-900 dark:[&_strong]:text-white">
{description}
</div>
)}
{(ctaPrimary || ctaSecondary) && (
<div className="mt-5 flex flex-wrap gap-3">
{ctaPrimary && (
<a
href={ctaPrimary.href}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-sm font-semibold text-white transition hover:opacity-90"
style={{
backgroundColor: 'var(--primary-500)',
boxShadow: '0 4px 14px color-mix(in srgb, var(--primary-500) 25%, transparent)',
}}
>
{ctaPrimary.label}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
)}
{ctaSecondary && (
<a
href={ctaSecondary.href}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-sm font-semibold text-primary-700 dark:text-primary-300 transition hover:bg-primary-50 dark:hover:bg-primary-500/10"
style={{ boxShadow: 'inset 0 0 0 1.5px color-mix(in srgb, var(--primary-500) 40%, transparent)' }}
>
{ctaSecondary.label}
</a>
)}
</div>
)}
{bullets && bullets.length > 0 && (
<ul className="mt-6 flex flex-col gap-3">
{bullets.map((b, i) => (
<li key={i} className="flex items-start gap-3 text-[15px] text-primary-800 dark:text-primary-200/80">
<span className="shrink-0 mt-0.5 inline-flex items-center justify-center w-5 h-5 rounded-full" style={{ backgroundColor: 'color-mix(in srgb, var(--primary-500) 18%, transparent)', color: 'var(--primary-500)' }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
<span className="leading-snug">{b}</span>
</li>
))}
</ul>
)}
{links && links.length > 0 && (
<div className="mt-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary-500 dark:text-primary-400/70 mb-3">
Explore more
</p>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<a
key={i}
href={l.href}
className="group inline-flex items-center justify-between gap-3 rounded-xl border border-primary-200/70 dark:border-primary-700/40 px-4 py-3 text-sm font-medium text-primary-800 dark:text-primary-200 transition hover:border-primary-400 hover:bg-primary-50 dark:hover:bg-primary-500/10"
>
<span>{l.label}</span>
<svg className="w-4 h-4 shrink-0 text-primary-400 transition group-hover:translate-x-0.5 group-hover:text-primary-600 dark:group-hover:text-primary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
))}
</div>
</div>
)}
{previewLarge && (
<div className={`mt-7 relative rounded-2xl overflow-hidden ${previewGradient || 'bg-gradient-to-br from-primary-100 via-primary-300 to-primary-600'}`}>
<div className="p-5 sm:p-7">
{previewLarge}
</div>
</div>
)}
</div>
</div>
</div>,
document.body
);
}
/* --------------------------- The Collapsed Card --------------------------- */
export default function ExpandableCard({
eyebrow,
title,
tagline,
expandedTitle,
description,
preview,
previewLarge,
previewGradient = 'bg-gradient-to-br from-primary-100 via-primary-300 to-primary-600',
ctaPrimary,
ctaSecondary,
bullets,
links,
compact = false,
mobileBar = false,
className = '',
cursorText = 'Expand',
}) {
const [open, setOpen] = useState(false);
const handleOpen = (e) => {
// Ignore clicks that originate on the corner button itself — that
// button has its own onClick. This prevents double-fire.
if (e.target.closest('button[aria-label="Expand"]')) return;
setOpen(true);
};
return (
<>
<div className="group relative h-full">
<div
onClick={handleOpen}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(true); } }}
tabIndex={0}
role="button"
aria-label={`Expand ${title}`}
data-cursor-text={cursorText}
style={{ WebkitMaskImage: CARD_NOTCH, maskImage: CARD_NOTCH }}
className={`relative h-full cursor-pointer rounded-2xl border border-primary-200/70 dark:border-primary-800/40 bg-white/90 dark:bg-secondary-900/90 backdrop-blur-sm overflow-hidden transition-all hover:border-primary-400/50 dark:hover:border-primary-600/40 hover:shadow-lg ${mobileBar ? 'flex flex-col sm:block active:scale-[0.985] active:bg-primary-50/60 dark:active:bg-primary-500/[0.06] sm:active:scale-100' : ''} ${className}`}
>
{/* Mobile-bar collapsed layout (mobileBar only, < sm = below 668): one
full-width horizontal row — leading accent dot + title left, corner
expand button right, no preview tile. Hidden at sm+ (≥668) where the
standard vertical card takes over.
Two density tiers via xs:
<442 → compact (py-3, tight gap, sm text)
442–667 → roomier (py-3.5, larger gap + title) for 2-col bars. */}
{mobileBar && (
<div className="flex sm:hidden items-center gap-2.5 xs:gap-3 px-3.5 xs:px-4 py-3 xs:py-3.5 pr-12">
{/* Leading accent dot — a quiet primary pip that gives the bar a
deliberate start and visual rhythm down a stacked column. */}
<span
aria-hidden
className="shrink-0 w-2 h-2 rounded-full"
style={{
backgroundColor: 'var(--primary-500)',
boxShadow: '0 0 0 3px color-mix(in srgb, var(--primary-500) 14%, transparent)',
}}
/>
<h3 className="flex-1 min-w-0 text-[13px] xs:text-sm font-semibold text-primary-950 dark:text-white truncate leading-tight">
{title}
</h3>
</div>
)}
{/* Header strip: eyebrow only — the expand control sits in the top-right
carved notch, so keep right padding to clear it. */}
<div className={`${mobileBar ? 'hidden sm:flex' : 'flex'} items-start gap-2 ${compact ? 'px-3 pt-3 pr-10' : 'px-4 sm:px-5 pt-4 sm:pt-5 pr-12'}`}>
{eyebrow && (
<p className={`font-semibold uppercase tracking-[0.16em] text-primary-600/85 dark:text-primary-400/80 ${compact ? 'text-[9px]' : 'text-[10px] sm:text-[11px]'}`}>
{eyebrow}
</p>
)}
</div>
{/* Title */}
<h3 className={`${mobileBar ? 'hidden sm:block' : ''} ${compact ? `px-3 ${tagline ? 'pb-1' : 'pb-3'} mt-1.5 text-[12px] leading-snug` : `px-4 sm:px-5 ${tagline ? 'pb-1' : 'pb-3 sm:pb-4'} mt-2 text-base sm:text-lg leading-snug`} font-semibold text-primary-950 dark:text-white line-clamp-3`}>
{title}
</h3>
{/* Tagline — short 3–5 word "at a glance" line under the title.
Optional: only renders when passed, so cards that don't supply one
(e.g. DashTeasers) are unchanged. Hidden in the mobileBar row. */}
{tagline && (
<p className={`${mobileBar ? 'hidden sm:block' : ''} ${compact ? 'px-3 pb-3' : 'px-4 sm:px-5 pb-3 sm:pb-4'} body-sm text-primary-950/55 dark:text-primary-200/55 leading-snug`}>
{tagline}
</p>
)}
{/* Preview slot — holds the mini-illustration. Slot aspect matches the
16:9 tile (aspect-video) so the animation fills it edge-to-edge with
no purple gradient bands and no wasted vertical height. No hover
scale (removed per design). Compact keeps its short 3:2 tile. */}
{preview && (
<div className={`${mobileBar ? 'hidden sm:block' : ''} relative mt-auto ${compact ? 'aspect-[3/2]' : 'aspect-[2/1]'} overflow-hidden`}>
<div className="absolute inset-0 flex items-center justify-center p-0">
{preview}
</div>
</div>
)}
</div>
{/* Corner expand button — sibling (unmasked), centred in the concave
scoop cut from the card's TOP-RIGHT corner. 36px button at 8px inset →
its centre lands at 26px, concentric with the notch, leaving an even
~7px gap ring that reveals the background so the corner reads as truly
cut out. Solid primary fill so it's a deliberate, confident control. */}
<button
type="button"
aria-label="Expand"
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
className="absolute top-2 right-2 z-10 inline-flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white shadow-md shadow-primary-600/30 transition-all hover:bg-primary-500 hover:scale-105 dark:bg-primary-500 dark:text-primary-950 dark:hover:bg-primary-400"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="m21 3-7 7" /><path d="m3 21 7-7" />
</svg>
</button>
</div>
<ExpandableSheet
open={open}
onClose={() => setOpen(false)}
title={expandedTitle || title}
description={description}
previewLarge={previewLarge || preview}
previewGradient={previewGradient}
ctaPrimary={ctaPrimary}
ctaSecondary={ctaSecondary}
bullets={bullets}
links={links}
/>
</>
);
}
components/velocity/expandable-card/useSheetDrag.js
/*
* VELOCITY COMPONENT · theming
*
* Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
* the Velocity preset. Out of the box they are a neutral indigo — NOT the
* Aidxn brand — so this drops into your project without dragging our look in.
*
* ⚡ NOT USING VELOCITY? Run this once, in your project root:
*
* velocityx theme
*
* It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
* tailwind.config, import the css, then set your whole brand in ONE place:
*
* :root {
* --vx-brand: custom;
* --vx-primary-600: 220 38 38; <- your action colour
* --vx-primary-700: 185 28 28;
* }
*
* ⚠️ Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
* breaks every /opacity variant in the library (bg-primary-600/20 etc).
*
* Prefer not to install anything? Find-and-replace `primary-` with your own
* scale name. Nothing else in this file depends on Velocity.
*/
import { useRef, useState, useCallback } from 'react';
/**
* useSheetDrag — pointer-event drag controller for the Stripe-style
* expandable sheet. Returns a `dragOffset` (px below resting position),
* handler props to spread on the drag-surface element, and an `isLive`
* flag so the consumer can suppress CSS transitions while the finger
* is on the screen.
*
* Rubber-band past 120px: every pixel beyond that point only counts
* for 25% of the actual finger travel, giving the elastic feel without
* a spring solver.
*
* Past `dismissThreshold` (default 160px) the sheet closes via the
* `onDismiss` callback. Below that, on pointer-up, the offset snaps
* back to 0 with a CSS transition.
*/
export default function useSheetDrag(onDismiss, dismissThreshold = 160) {
const startY = useRef(null);
const [dragOffset, setDragOffset] = useState(0);
const [isLive, setIsLive] = useState(false);
const onPointerDown = useCallback((e) => {
startY.current = e.clientY;
setIsLive(true);
e.currentTarget.setPointerCapture?.(e.pointerId);
}, []);
const onPointerMove = useCallback((e) => {
if (startY.current == null) return;
const raw = e.clientY - startY.current;
if (raw <= 0) {
setDragOffset(0);
return;
}
const offset = raw <= 120 ? raw : 120 + (raw - 120) * 0.25;
setDragOffset(offset);
}, []);
const onPointerUp = useCallback(() => {
if (startY.current == null) return;
const shouldDismiss = dragOffset > dismissThreshold;
startY.current = null;
setIsLive(false);
setDragOffset(0);
if (shouldDismiss) onDismiss?.();
}, [dragOffset, dismissThreshold, onDismiss]);
const dragHandlers = {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel: onPointerUp,
};
return { dragOffset, dragHandlers, isLive };
}
Theming
Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.