Tilt Card

A card that leans towards the pointer, with layers inside it that move by their depth.

'use client';

import type { ReactNode } from 'react';
import { TiltCard, type TiltCardColor } from '@/components/panelui/tilt-card';

/** Three sheets, one over the other, each lifted further off the card. */
function Sheets() {
  return (
    <div className="relative h-24 w-28">
      <TiltCard.Layer depth={6} className="absolute inset-x-4 top-8 h-14 rounded-xl border bg-muted" />
      <TiltCard.Layer depth={18} className="absolute inset-x-2 top-4 h-14 rounded-xl border bg-card shadow-sm" />
      <TiltCard.Layer
        depth={32}
        className="absolute inset-x-0 top-0 flex h-14 items-center gap-2 rounded-xl border bg-card px-3 shadow-md"
      >
        <span className="size-2 rounded-full bg-(--success)" />
        <span className="h-1.5 w-12 rounded-full bg-muted-foreground/40" />
      </TiltCard.Layer>
    </div>
  );
}

/** Two rings and a body on the outer one. */
function Orbit() {
  return (
    <div className="relative size-24">
      <TiltCard.Layer depth={6} className="absolute inset-0 rounded-full border border-dashed border-muted-foreground/40" />
      <TiltCard.Layer depth={16} className="absolute inset-5 rounded-full border border-muted-foreground/50" />
      <TiltCard.Layer depth={28} className="absolute inset-9 rounded-full bg-foreground" />
      <TiltCard.Layer depth={36} className="absolute -top-1 left-1/2 size-3 -translate-x-1/2 rounded-full bg-(--info)" />
    </div>
  );
}

/** A field of marks, with one of them picked out and nearer. */
function Field() {
  return (
    <div className="relative grid size-24 grid-cols-4 gap-2.5">
      {Array.from({ length: 16 }, (_, index) =>
        index === 6 ? (
          <TiltCard.Layer key={index} depth={34} className="rounded-md bg-(--warning) shadow-md" />
        ) : (
          <TiltCard.Layer key={index} depth={index % 3 === 0 ? 14 : 6} className="rounded-md bg-muted-foreground/25" />
        )
      )}
    </div>
  );
}

const CARDS: { title: string; body: string; color: TiltCardColor; figure: ReactNode }[] = [
  { title: 'Versions', body: 'Every save is kept, and any of them can be brought back.', color: 'success', figure: <Sheets /> },
  { title: 'Sync', body: 'Changes reach your other devices within a few seconds.', color: 'info', figure: <Orbit /> },
  { title: 'Search', body: 'One box finds a word in any note, however old.', color: 'warning', figure: <Field /> },
];

export default function TiltCardFigures() {
  return (
    <div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
      {CARDS.map((card) => (
        <TiltCard key={card.title} color={card.color} cardClassName="flex h-full flex-col gap-6 p-5">
          <div className="flex h-32 items-center justify-center">{card.figure}</div>
          <TiltCard.Layer depth={10} className="flex flex-col gap-1">
            <h3 className="text-base font-semibold">{card.title}</h3>
            <p className="text-sm text-muted-foreground">{card.body}</p>
          </TiltCard.Layer>
        </TiltCard>
      ))}
    </div>
  );
}

Use it where a card is there to be looked at more than read: a feature on a landing page, a plan, something collectable. The card leans a few degrees towards the pointer and a soft light sits under it. Layers inside the card are lifted off it and move against it as it leans, which gives a flat drawing a front and a back.

The lean is decoration. It does nothing on a touch screen and holds still under reduced motion, so anything the card has to say goes in its contents.

For cards in a dense grid that someone works through, such as a list of records, use a plain card. A screen where everything leans is tiring to use.

Installation

npx shadcn@latest add https://panelui.dev/r/web/tilt-card.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/tilt-card instead of @/components/panelui/tilt-card. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install tailwind-variants

Copy the source into components/panelui/tilt-card.tsx:

Usage

import { TiltCard } from "@/components/panelui/tilt-card"
<TiltCard color="info" cardClassName="p-5">
  <TiltCard.Layer depth={24}>{figure}</TiltCard.Layer>
  <TiltCard.Layer depth={8}>
    <h3>Sync</h3>
  </TiltCard.Layer>
</TiltCard>

Composition

<TiltCard>
  <TiltCard.Layer depth={…}>…</TiltCard.Layer>
</TiltCard>
  • TiltCard.Layer — Something lifted off the card by depth, in pixels. The further out, the further it moves as the card leans.

Examples

Without layers or light

A card with nothing but its contents still leans. tilt is how far, in degrees, and glare={false} leaves out the light under the pointer.

'use client';

import { TiltCard } from '@/components/panelui/tilt-card';

export default function TiltCardPlain() {
  return (
    <TiltCard tilt={12} glare={false} className="w-full max-w-xs" cardClassName="p-5">
      <p className="text-sm text-muted-foreground">Team plan</p>
      <p className="mt-1 text-3xl font-bold tracking-tight">
        €12 <span className="text-sm font-medium text-muted-foreground">a seat, monthly</span>
      </p>
      <p className="mt-3 text-sm text-muted-foreground">
        Shared notebooks, version history for a year, and single sign-on.
      </p>
    </TiltCard>
  );
}

API Reference

TiltCard

PropTypeDefaultDescription
tiltnumber8The furthest the card leans, in degrees.
colorTiltCardColor'primary'Which of the theme's roles the light under the pointer is drawn in.
glarebooleantrueSet to false for a card with no light under the pointer.
cardClassNamestring—Extra classes for the card itself. className goes on the box round it.

TiltCard.Layer

PropTypeDefaultDescription
depthnumber—How far off the card it is lifted, in pixels. Further out moves further as the card leans.

Every part also takes the props of the element it renders, and a className.

Notes

Two boxes

The component is a box that sets the viewpoint, with the card inside it. className goes on the outer box, which is where a width belongs. cardClassName goes on the card, which is where padding and layout belong.

Depth

A layer's depth is how many pixels it stands off the card. At the edge of the card a layer moves by about that many pixels, so depths between 6 and 36 read well, and anything over 50 starts to come apart from the card. Text reads best at 12 or under.

Layers can be positioned however you like, absolutely included. A layer's own transform is used for the movement, so put any transform of your own on an element inside it.

Colour

color names one of the theme's roles and tints the light under the pointer. It defaults to primary.

What it is clipped by

The card clips its contents to its rounded corners, so a layer that moves past the edge is cut off there. Leave a layer room to move.

Touch and reduced motion

The card does not lean under a finger, and does not lean at all for anyone who has asked their system for reduced motion. It is then a card.

Server Components

TiltCard is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read TiltCard.Layer off the import, so each part is also exported by name: TiltCardLayer is TiltCard.Layer.

import { TiltCard, TiltCardLayer } from "@/components/panelui/tilt-card"

On this page