Swish UI
All componentsCards and layout Free

Card hover lift

Cards rise 3px and tint their border on hover while a small illustration inside comes alive, like a clock hand sweeping.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import styles from "./demo.module.css";

const cards = [
  { viz: "clock", title: "Stay a little longer", text: "A late flight is a good reason to keep the keys for a few more hours.", tag: "Late checkout" },
  { viz: "before", title: "Start one day sooner", text: "An open night before arrival becomes an invite to begin early.", tag: "Extra night" },
  { viz: "gap", title: "Close the little gaps", text: "Sell the single nights hiding between two bookings.", tag: "Gap night" },
];

export default function Demo() {
  return (
    <div className={styles.root}>
      <div className={styles.row}>
        {cards.map((c) => (
          <a key={c.tag} href="#" className={styles.card} onClick={(e) => e.preventDefault()}>
            {c.viz === "clock" ? (
              <span className={`${styles.viz} ${styles.clock}`} aria-hidden="true"><span /></span>
            ) : (
              <span className={`${styles.viz} ${styles.nights} ${styles[c.viz]}`} aria-hidden="true">
                <i /><i /><i /><i />
              </span>
            )}
            <b>{c.title}</b>
            <p>{c.text}</p>
            <span className={styles.tag}>{c.tag}</span>
          </a>
        ))}
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; container-type: inline-size; overflow: hidden; }
.row { width: 100%; max-width: 860px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.card { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 20px; border-radius: 16px; background: var(--white); border: 1px solid var(--line); text-decoration: none; transition: transform .18s var(--out), border-color .18s; }
.card:hover, .card:focus-visible { transform: translateY(-3px); border-color: var(--accent-text); }
.card:active { transform: translateY(-1px); }
.card b { font-size: 16px; line-height: 1.2; letter-spacing: -0.015em; }
.card p { font-size: 14px; color: var(--muted); }
.tag { margin-top: auto; padding-top: 4px; font-size: 13px; font-weight: 600; color: var(--accent-text); }

.viz { height: 52px; display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
/* Clock: the minute hand sweeps forward on hover. */
.clock { position: relative; width: 50px; height: 50px; border-radius: 50%; border: 2px solid var(--ink); margin-top: 1px; }
.clock span, .clock::before { content: ""; position: absolute; left: calc(50% - 1px); bottom: 50%; width: 2px; border-radius: 1px; background: var(--ink); transform-origin: 50% 100%; }
.clock::before { height: 12px; transform: rotate(120deg); }
.clock span { height: 18px; background: var(--accent-text); transform: rotate(60deg); transition: transform .6s var(--out); }
.card:hover .clock span, .card:focus-visible .clock span { transform: rotate(150deg); }
/* Nights: a highlighted night slides in on hover. */
.nights i { width: 28px; height: 28px; border-radius: 8px; background: var(--sand); transition: background-color .3s, transform .4s var(--out); }
.before i:first-child { background: var(--accent-soft); transform: translateX(-6px); opacity: .5; transition: transform .45s var(--out), opacity .3s; }
.card:hover .before i:first-child, .card:focus-visible .before i:first-child { transform: none; opacity: 1; }
.gap i:nth-child(3) { background: var(--surface); outline: 1.5px dashed var(--line); outline-offset: -1.5px; }
.card:hover .gap i:nth-child(3), .card:focus-visible .gap i:nth-child(3) { background: var(--accent-soft); outline-color: transparent; transform: scale(1.08); }

/* Narrow tiles: drop the body copy and tighten. */
@container (max-width: 560px) {
  .row { gap: 8px; }
  .card { padding: 14px 12px; gap: 6px; border-radius: 14px; }
  .card p { display: none; }
  .card b { font-size: 14px; }
  .tag { font-size: 12px; }
  .viz { height: 40px; margin-bottom: 2px; }
  .clock { width: 38px; height: 38px; }
  .clock span { height: 13px; } .clock::before { height: 9px; }
  .nights i { width: 18px; height: 18px; border-radius: 5px; }
  .viz.nights { gap: 3px; }
}
@media (prefers-reduced-motion: reduce) {
  .card:hover, .card:focus-visible, .card:active { transform: none; }
  .clock span, .nights i { transition: none; }
}

Use it for

  • Feature or offer cards that link somewhere
  • Grids where you want hover to feel crafted without adding shadows

Avoid

  • Cards that are not clickable, since lift promises an action
  • Dense tables or lists, where movement is noise

How it works

Each card is a link with a transform and border colour transition of 180ms on the shared ease out curve; :hover and :focus-visible move it up 3px and switch the border to the accent. The illustration is pure CSS: the clock minute hand rotates from 60 to 150 degrees over 600ms, a night tile slides into place, and a dashed gap night fills in. Only transform, opacity and colours change.

Accessibility

Keyboard focus triggers the same state as hover, so the detail is not mouse only. Reduced motion keeps the border tint but drops the lift and the illustration movement. Illustrations are aria-hidden.

Found on