Label CardsColour-blocked label cards scattered at random angles.
Cards & ModalsInteractive
Switch to light
Refresh
Full screen
✳Mobile Apps
Since 2023Native iOS & Android
Interaction Design
AI
CANVAS✳Web Design
Since 2021React & Next.js
Frontend Systems
AI
CANVAS✳Branding
Since 2020Visual Identity
Brand Strategy
AI
CANVAS✳Editorial
Since 2022Magazine & Print
Typography Systems
AI
CANVAS✳Motion
Since 2023Animation & Micro-UX
Interaction Patterns
AI
CANVAStap a card to focus
'use client'
// npm install framer-motion
import { useRef, useState, useLayoutEffect, useEffect } from 'react'
import { motion } from 'framer-motion'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
// ─── Base constants (designed at 480px container width) ──────────────────────
const BASE_CARD_W = 300
const BASE_CARD_H = 169 // 16:9
// ─── Card data ───────────────────────────────────────────────────────────────
const CARDS = [
{
id: 0,
title: 'Mobile Apps',
sub1: 'Native iOS & Android',
sub2: 'Interaction Design',
since: '2023',
bg: '#5CAD60',
fg: '#162217',
idle: { x: -82, y: -70, rotate: -11 },
},
{
id: 1,
title: 'Web Design',
sub1: 'React & Next.js',
sub2: 'Frontend Systems',
since: '2021',
bg: '#EDD540',
fg: '#241F05',
idle: { x: -48, y: 66, rotate: 9 },
},
{
id: 2,
title: 'Branding',
sub1: 'Visual Identity',
sub2: 'Brand Strategy',
since: '2020',
bg: '#1C2E8A',
fg: '#E8EDF8',
idle: { x: 12, y: -14, rotate: -4 },
},
{
id: 3,
title: 'Editorial',
sub1: 'Magazine & Print',
sub2: 'Typography Systems',
since: '2022',
bg: '#D43C3C',
fg: '#FAE8E8',
idle: { x: 68, y: 62, rotate: 14 },
},
{
id: 4,
title: 'Motion',
sub1: 'Animation & Micro-UX',
sub2: 'Interaction Patterns',
since: '2023',
bg: '#E08030',
fg: '#2A1A06',
idle: { x: 84, y: -86, rotate: 18 },
},
]
const BAR_PATTERN = [1,2,1,1,3,1,2,1,1,3,1,1,2,1,2,1,3,1,1,2,1,3,1,2,1,1,2,1]
const SPRING_SCATTER = { type: 'spring' as const, stiffness: 240, damping: 24 }
const SPRING_FOCUS = { type: 'spring' as const, stiffness: 340, damping: 28 }
// ─── Sub-components ───────────────────────────────────────────────────────────
function Barcode({ color, scale }: { color: string; scale: number }) {
return (
<div style={{ display: 'flex', alignItems: 'stretch', height: 28 * scale, gap: 1.2 * scale }}>
{BAR_PATTERN.map((w, i) => (
<div
key={i}
style={{ width: w * 1.5 * scale, background: color, opacity: 0.8, borderRadius: 0.5 }}
/>
))}
</div>
)
}
function LogoMark({ color, scale }: { color: string; scale: number }) {
const size = 28 * scale
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 * scale }}>
<div
style={{
width: size,
height: size,
borderRadius: '50%',
border: `${1.5 * scale}px solid ${color}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: 0.85,
}}
>
<span style={{ fontSize: 7.5 * scale, fontWeight: 800, color, letterSpacing: '0.02em', lineHeight: 1 }}>
AI
</span>
</div>
<span style={{ fontSize: 6.5 * scale, fontWeight: 700, color, letterSpacing: '0.12em', textTransform: 'uppercase', opacity: 0.7 }}>
CANVAS
</span>
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function LabelCards() {
const containerRef = useRef<HTMLDivElement>(null)
const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
const [selected, setSelected] = useState<number | null>(null)
const [containerW, setContainerW] = useState(480)
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
// ── Theme detection ────────────────────────────────────────────────────────
const checkTheme = () => {
const card = el.closest('[data-card-theme]')
setIsDark(
card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark'),
)
}
checkTheme()
const themeObs = new MutationObserver(checkTheme)
themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el.closest('[data-card-theme]')
if (cardWrapper) themeObs.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
// ── Size tracking ──────────────────────────────────────────────────────────
const sizeObs = new ResizeObserver(entries => {
setContainerW(entries[0].contentRect.width)
})
sizeObs.observe(el)
return () => { themeObs.disconnect(); sizeObs.disconnect() }
}, [])
// ── Responsive scaling ─────────────────────────────────────────────────────
// Divide by 1.56 ensures the widest card (offset + half-width) stays in-frame
const cardW = Math.min(BASE_CARD_W, Math.floor(containerW / 1.56))
const cardH = Math.round(cardW * 9 / 16)
const scale = cardW / BASE_CARD_W
return (
<div
ref={containerRef}
className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[#E8E8DF] dark:bg-[#1A1A19]"
>
{/* Dot grid */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `radial-gradient(circle, ${
isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)'
} 1px, transparent 1px)`,
backgroundSize: '28px 28px',
}}
/>
{CARDS.map(card => {
const isSelected = selected === card.id
return (
<motion.div
key={card.id}
style={{
position: 'absolute',
width: cardW,
height: cardH,
borderRadius: 10 * scale,
overflow: 'hidden',
cursor: 'pointer',
background: card.bg,
boxShadow: '0 10px 36px rgba(0,0,0,0.32)',
userSelect: 'none',
}}
animate={
isSelected
? { x: 0, y: 0, rotate: 0, scale: 1.05, zIndex: 10, opacity: 1 }
: {
x: card.idle.x * scale,
y: card.idle.y * scale,
rotate: card.idle.rotate,
scale: 1,
zIndex: 2,
opacity: 1,
}
}
transition={isSelected ? SPRING_FOCUS : SPRING_SCATTER}
onClick={() => setSelected(prev => (prev === card.id ? null : card.id))}
>
<div
style={{
height: '100%',
padding: `${12 * scale}px ${14 * scale}px ${11 * scale}px`,
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
}}
>
{/* Title row */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 9 * scale,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 * scale }}>
<span style={{ fontSize: 16 * scale, fontWeight: 900, color: card.fg, lineHeight: 1, opacity: 0.9 }}>
✳
</span>
<span style={{ fontSize: 18 * scale, fontWeight: 800, color: card.fg, letterSpacing: '-0.025em', lineHeight: 1 }}>
{card.title}
</span>
</div>
<span style={{ fontSize: 9 * scale, fontWeight: 600, color: card.fg, opacity: 0.5, letterSpacing: '0.08em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
Since {card.since}
</span>
</div>
{/* Divider */}
<div style={{ height: 1, background: card.fg, opacity: 0.22, marginBottom: 9 * scale, flexShrink: 0 }} />
{/* Body */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<p style={{ margin: 0, fontSize: 11.5 * scale, fontWeight: 600, color: card.fg, lineHeight: 1.35 }}>
{card.sub1}
</p>
<p style={{ margin: `${3 * scale}px 0 0`, fontSize: 11 * scale, fontWeight: 400, color: card.fg, opacity: 0.6, lineHeight: 1.35 }}>
{card.sub2}
</p>
</div>
{/* Bottom bar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Barcode color={card.fg} scale={scale} />
<LogoMark color={card.fg} scale={scale} />
</div>
</div>
</div>
</motion.div>
)
})}
{/* Hint */}
<motion.p
style={{
position: 'absolute',
bottom: 20,
fontSize: 11,
letterSpacing: '0.05em',
color: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)',
pointerEvents: 'none',
}}
animate={{ opacity: selected !== null ? 0 : 1 }}
transition={{ duration: 0.3 }}
>
tap a card to focus
</motion.p>
</div>
)
}
Add to your project
One command adds this component to your project.
1
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/label-cards2
For dark mode, add the dark class to your <html> element:
<html class="dark">

