Browse Source

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
pull/5966/head
David Requeno 3 weeks ago
parent
commit
65cbbafa5f
  1. 100
      libs/ui/src/lib/membership-card/membership-card.component.html
  2. 221
      libs/ui/src/lib/membership-card/membership-card.component.scss
  3. 9
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  4. 1
      libs/ui/src/lib/membership-card/membership-card.component.ts

100
libs/ui/src/lib/membership-card/membership-card.component.html

@ -1,50 +1,62 @@
<div
class="card-container position-relative"
[ngClass]="{ premium: name === 'Premium' }"
>
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
<div class="card-wrapper" [class.hover-3d-enabled]="hover3d">
<div
class="card-container position-relative"
[ngClass]="{ premium: name === 'Premium' }"
>
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
</div>
</a>
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
</div>
</div>
}
</div>
</a>
</div>
@if (hover3d) {
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
<div class="hover-zone"></div>
}
</div>

221
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;
}
}
}
}

9
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<GfMembershipCardComponent>;
@ -37,7 +40,8 @@ type Story = StoryObj<GfMembershipCardComponent>;
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
}
};

1
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<void>();

Loading…
Cancel
Save