Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/vibes/soul/docs/gift-card.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Gift Card
preview: gift-card-example
---
7 changes: 7 additions & 0 deletions apps/web/vibes/soul/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,5 +1156,12 @@ export const examples = [
files: ['examples/primitives/scroll-area/luxury.tsx'],
component: lazy(() => import('./examples/primitives/scroll-area/luxury')),
},
{
name: 'gift-card-example',
dependencies: [],
registryDependencies: ['gift-card'],
files: ['examples/primitives/gift-card/index.tsx'],
component: lazy(() => import('./examples/primitives/gift-card')),
},
// PLOP: Append new component here
] satisfies Components;
11 changes: 11 additions & 0 deletions apps/web/vibes/soul/examples/primitives/gift-card/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import { GiftCard } from '@/vibes/soul/primitives/gift-card';

export default function Preview() {
return (
<div className="flex h-screen items-center justify-center p-5">
<GiftCard amount="$25.00" logo="Planted" />
</div>
);
}
1 change: 1 addition & 0 deletions apps/web/vibes/soul/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const primitivePages: [Page, ...Page[]] = [
component: 'dropdown-menu',
},
{ title: 'Favorite', slug: 'favorite', file: 'docs/favorite.mdx', component: 'favorite' },
{ title: 'Gift Card', slug: 'gift-card', file: 'docs/gift-card.mdx', component: 'gift-card' },
{ title: 'Icon', slug: 'icon', file: 'docs/icon.mdx', component: 'icon' },
{
title: 'Logo',
Expand Down
113 changes: 113 additions & 0 deletions apps/web/vibes/soul/primitives/gift-card/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

import { useMemo, useRef, useState } from 'react';

export interface GiftCardProps {
maxTilt?: number;
amount: string | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
amount: string | null;
balance: string | null;
expiresAt?: string | null;
expiresAtLabel?: string;
status?: 'ACTIVE' | 'EXPIRED' | 'PENDING' | 'DISABLED';

Status and expires at will probably be catalyst specific, but figured i'd leave a comment anyways

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth adding since we're not really thinking about the components as Catalyst-specific anymore.

logo: string;
loading?: boolean;
}

export function GiftCard({
maxTilt = 15,
amount = '$25.00',
logo = 'Planted',
loading = false,
}: GiftCardProps) {
const cardRef = useRef<HTMLDivElement>(null);

const [mouse, setMouse] = useState({ x: 0, y: 0, xPct: 50, yPct: 50 });
const [tilt, setTilt] = useState({ rotateX: 0, rotateY: 0, z: 0 });

// Derived background positions for holo layers
const bgX = useMemo(() => 40 + 0.2 * mouse.xPct, [mouse.xPct]);
const bgY = useMemo(() => 40 + 0.2 * mouse.yPct, [mouse.yPct]);

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
const target = e.currentTarget;
const rect = target.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const xPct = (x / rect.width) * 100;
const yPct = (y / rect.height) * 100;
setMouse({ x, y, xPct, yPct });
}

function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
const el = cardRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const xPercent = x / rect.width;
const yPercent = y / rect.height;
setTilt({
rotateX: maxTilt * (0.5 - yPercent),
rotateY: maxTilt * (xPercent - 0.5),
z: -10,
});
}

function handlePointerEnter() {
setTilt((t) => ({ ...t, z: -10 }));
}

function handlePointerLeave() {
setTilt({ rotateX: 0, rotateY: 0, z: 0 });
setMouse({ x: 0, y: 0, xPct: 50, yPct: 50 });
}

return (
<div
className="group/spotlight border-contrast-400 @container relative aspect-[73/46] w-full max-w-182 overflow-hidden rounded-3xl border bg-gradient-to-b from-[#212B1B] from-[32%] to-[#3C4E31] transition-transform duration-300 ease-out select-none perspective-distant transform-3d"
Comment thread
hunterbecton marked this conversation as resolved.
Outdated
onMouseMove={handleMouseMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
ref={cardRef}
style={{
transform: `translateZ(${tilt.z}px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)`,
}}
>
<div className="grid h-full w-full grid-cols-2 px-[clamp(1.5rem,7.7cqw,3.5rem)] py-[clamp(1rem,5.5cqw,2.5rem)]">
{/* Left column */}
<div>
<p className="font-heading text-primary-highlight text-[clamp(1.5rem,8.25cqw,3.75rem)] leading-[clamp(1.75rem,8.8cqw,4rem)] font-light tracking-tight">
{logo}
</p>
<p className="text-contrast-100 font-body text-[clamp(0.875rem,3.3cqw,1.5rem)] leading-[clamp(1.25rem,3.85cqw,1.75rem)] tracking-tight">
Gift card
</p>
</div>

{/* Right column */}
<div className="place-self-end">
{amount !== null && !loading && (
<p className="font-heading text-primary-highlight text-[clamp(2rem,13.2cqw,6rem)] leading-none font-normal tracking-tight">
{amount}
</p>
)}
{loading && <div className="bg-contrast-100 h-10 w-10 animate-spin rounded-full" />}
</div>

{/* Cursor mask */}
<div
className="bg-primary-highlight pointer-events-none absolute -inset-px z-0 rounded-md mask-no-repeat opacity-0 mix-blend-soft-light transition-opacity duration-300 group-hover/spotlight:opacity-50"
style={{
maskImage: `radial-gradient(50cqmin circle at ${mouse.x}px ${mouse.y}px, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.45) 40%, rgba(255,255,255,0.15) 65%, transparent 85%)`,
}}
/>

{/* Holographic overlay */}
<div
className="pointer-events-none absolute -inset-px z-[1] [background-size:200%_700%,_300%,_300%] opacity-0 [background-blend-mode:hue,hue,hue,overlay] mix-blend-color-dodge transition-opacity duration-300 ease-out will-change-[position] group-hover/spotlight:opacity-10"
style={{
backgroundImage: `repeating-linear-gradient(0deg, rgba(255,119,115,1) 0%, rgba(255,237,95,1) 10%, rgba(168,255,95,1) 20%, rgba(131,255,247,1) 30%, rgba(120,148,255,1) 40%, rgba(216,117,255,1) 50%, rgba(255,119,115,1) 60%), repeating-linear-gradient(128deg, #0e152e 0%, hsl(180 10% 60%) 3.8%, hsl(180 10% 60%) 4.5%, hsl(180 10% 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at ${mouse.xPct}% ${mouse.yPct}%, rgba(255,255,255,0.10) 12%, rgba(255,255,255,0.15) 20%, rgba(255,255,255,0.25) 120%)`,
backgroundPosition: `${bgX}% ${bgY}%, ${bgX}% ${bgY}%, ${bgX}% ${bgY}%`,
}}
/>
</div>
</div>
);
}
40 changes: 20 additions & 20 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading