From 65cbbafa5fc2ca3d1019699bae80b8413d2469f0 Mon Sep 17 00:00:00 2001 From: David Requeno Date: Thu, 20 Nov 2025 23:07:43 -0600 Subject: [PATCH] Feature/add 3D hover effect to membership card component - Add @Input() hover3d property to enable/disable 3D hover effect - Implement 8 hover zones for dynamic 3D tilt effect - Add CSS-only 3D transformations based on mouse position - Update Storybook stories with hover3d --- .../membership-card.component.html | 100 ++++---- .../membership-card.component.scss | 221 ++++++++++++++++++ .../membership-card.component.stories.ts | 9 +- .../membership-card.component.ts | 1 + 4 files changed, 285 insertions(+), 46 deletions(-) diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html index 1c68f5e3f..d9449c8a8 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.html +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -1,50 +1,62 @@ -
- +
-
- -
- @if (hasPermissionToCreateApiKey) { -
-
API Key
-
-
* * * * * * * * *
-
- -
-
+
+
+
- } -
-
-
Membership
-
{{ name }}
-
- @if (expiresAt) { -
-
Valid until
-
- {{ expiresAt }} + @if (hasPermissionToCreateApiKey) { +
+
API Key
+
+
* * * * * * * * *
+
+ +
} -
-
+
+
+
Membership
+
{{ name }}
+
+ @if (expiresAt) { +
+
Valid until
+
+ {{ expiresAt }} +
+
+ } +
+ +
+ @if (hover3d) { +
+
+
+
+
+
+
+
+ }
diff --git a/libs/ui/src/lib/membership-card/membership-card.component.scss b/libs/ui/src/lib/membership-card/membership-card.component.scss index 270adc0f1..5016b98ca 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.scss +++ b/libs/ui/src/lib/membership-card/membership-card.component.scss @@ -7,6 +7,14 @@ padding-top: calc(1 * var(--borderWidth)); width: 100%; + .card-wrapper { + position: relative; + + &.hover-3d-enabled { + perspective: 1000px; + } + } + .card-container { border-radius: var(--borderRadius); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); @@ -69,4 +77,217 @@ } } } + + .hover-3d-enabled { + .card-container { + transition: + transform 300ms ease, + box-shadow 300ms ease; + transform-style: preserve-3d; + will-change: transform; + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + circle at 50% 0%, + rgba(255, 255, 255, 0.25), + transparent 60% + ); + opacity: 0; + mix-blend-mode: screen; + transition: opacity 300ms ease; + pointer-events: none; + border-radius: var(--borderRadius); + z-index: 1; + } + } + + .hover-zone { + position: absolute; + width: 33.333%; + height: 33.333%; + pointer-events: auto; + z-index: 2; + + &:nth-child(3) { + top: 0; + left: 0; + } + + &:nth-child(4) { + top: 0; + left: 33.333%; + } + + &:nth-child(5) { + top: 0; + right: 0; + } + + &:nth-child(6) { + top: 33.333%; + left: 0; + } + + &:nth-child(7) { + top: 33.333%; + right: 0; + } + + &:nth-child(8) { + bottom: 0; + left: 0; + } + + &:nth-child(9) { + bottom: 0; + left: 33.333%; + } + + &:nth-child(10) { + bottom: 0; + right: 0; + } + } + + &:has(.hover-zone:nth-child(3):hover) .card-container { + transform: perspective(1000px) rotateX(5deg) rotateY(-5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 0% 0%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(4):hover) .card-container { + transform: perspective(1000px) rotateX(5deg) rotateY(0deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 50% 0%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(5):hover) .card-container { + transform: perspective(1000px) rotateX(5deg) rotateY(5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 100% 0%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(6):hover) .card-container { + transform: perspective(1000px) rotateX(0deg) rotateY(-5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 0% 50%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(7):hover) .card-container { + transform: perspective(1000px) rotateX(0deg) rotateY(5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 100% 50%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(8):hover) .card-container { + transform: perspective(1000px) rotateX(-5deg) rotateY(-5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 0% 100%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(9):hover) .card-container { + transform: perspective(1000px) rotateX(-5deg) rotateY(0deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 50% 100%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + + &:has(.hover-zone:nth-child(10):hover) .card-container { + transform: perspective(1000px) rotateX(-5deg) rotateY(5deg) + translateY(-2px); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + + &::before { + opacity: 1; + background: radial-gradient( + circle at 100% 100%, + rgba(255, 255, 255, 0.35), + transparent 60% + ); + } + } + } + + @media (prefers-reduced-motion: reduce) { + .hover-3d-enabled { + .card-container { + transition: none !important; + transform: none !important; + + &::before { + transition: none !important; + } + } + + &:has(.hover-zone:hover) .card-container { + transform: none !important; + } + } + } } diff --git a/libs/ui/src/lib/membership-card/membership-card.component.stories.ts b/libs/ui/src/lib/membership-card/membership-card.component.stories.ts index 6b6fbe038..968eb8fc0 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.stories.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.stories.ts @@ -29,6 +29,9 @@ export default { name: { control: { type: 'select' }, options: ['Basic', 'Premium'] + }, + hover3d: { + control: { type: 'boolean' } } } } as Meta; @@ -37,7 +40,8 @@ type Story = StoryObj; export const Basic: Story = { args: { - name: 'Basic' + name: 'Basic', + hover3d: false } }; @@ -45,6 +49,7 @@ export const Premium: Story = { args: { expiresAt: addYears(new Date(), 1).toLocaleDateString(), hasPermissionToCreateApiKey: true, - name: 'Premium' + name: 'Premium', + hover3d: false } }; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index 175a94f42..be223758d 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -34,6 +34,7 @@ import { GfLogoComponent } from '../logo'; export class GfMembershipCardComponent { @Input() public expiresAt: string; @Input() public hasPermissionToCreateApiKey: boolean; + @Input() public hover3d = false; @Input() public name: string; @Output() generateApiKeyClicked = new EventEmitter();