Skip to content
Velocity X Docs
← All components

Expandable Card

Free · MIT

Card 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

bash
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

tsx
/*
 *    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

tsx
/*
 *    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.