Skip to content

Commit de34d00

Browse files
siltomatoNateowami
andauthored
SF-2193 Add bubble splash effect that can be added to various buttons (#2013)
Co-authored-by: Nathaniel Paulus <[email protected]>
1 parent c3389f3 commit de34d00

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* eslint-disable @angular-eslint/directive-selector */
2+
import { Directive, ElementRef, OnInit, Renderer2 } from '@angular/core';
3+
4+
/**
5+
* Directive to add bubble animation to a button. Inspired by https://codepen.io/nourabusoud/pen/ypZzMM,
6+
* but modified to use an inner `<span>` to attach the pseudo elements to so as not to conflict
7+
* with other styles or components that use `::before` or `::after` such as Angular Material components.
8+
*/
9+
@Directive({
10+
selector: '[sfBubbleButton]'
11+
})
12+
export class BubbleButtonDirective implements OnInit {
13+
cssInnerSpanStyleClass = 'sf-bubble-button-elements';
14+
cssButtonStyleClass = 'sf-bubble-button';
15+
cssButtonAnimationClass = 'sf-bubble-animate';
16+
17+
constructor(
18+
private readonly el: ElementRef,
19+
private readonly renderer: Renderer2
20+
) {}
21+
22+
ngOnInit(): void {
23+
const hostElement = this.el.nativeElement;
24+
const innerSpan = this.renderer.createElement('span');
25+
26+
// Add inner span to host element
27+
this.renderer.addClass(innerSpan, this.cssInnerSpanStyleClass);
28+
this.renderer.appendChild(hostElement, innerSpan);
29+
30+
// Add class and click listener to host element
31+
this.renderer.addClass(hostElement, this.cssButtonStyleClass);
32+
this.renderer.listen(hostElement, 'click', () => {
33+
// Add animation class to inner span
34+
this.addAnimationClass(innerSpan);
35+
});
36+
}
37+
38+
// Adds animation class to the element and removes it after animation is complete
39+
addAnimationClass(el: any): void {
40+
// Reset animation
41+
el.classList.remove(this.cssButtonAnimationClass);
42+
43+
// Timeout needed to restart animation
44+
setTimeout(() => {
45+
el.classList.add(this.cssButtonAnimationClass);
46+
}, 10);
47+
}
48+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
@use 'sass:map';
2+
@use 'sass:list';
3+
@use 'src/variables' as vars;
4+
5+
/// Gets the min/max css function to constrain the size within a min and max size in px.
6+
/// @param {number} $sizePercentage - The size of the bubble as a percentage of the button size.
7+
@function sizeCalc($sizePercentage) {
8+
@return min(max(#{$minBubbleSize}, #{$sizePercentage}), #{$maxBubbleSize});
9+
}
10+
11+
$minBubbleSize: 30px;
12+
$maxBubbleSize: 50px;
13+
$bubbleColor: vars.$blueMedium;
14+
$animationDuration: 0.7s;
15+
16+
// Bubble types
17+
// 1: Large solid bubble
18+
// 2: Large outlined bubble
19+
// 3: Small solid bubble
20+
$bubbleTypes: (
21+
1: radial-gradient(circle, $bubbleColor 20%, transparent 20%),
22+
2: radial-gradient(circle, transparent 20%, $bubbleColor 20%, transparent 30%),
23+
3: radial-gradient(circle, transparent 10%, $bubbleColor 15%, transparent 20%)
24+
);
25+
26+
// prettier-ignore
27+
$bubbles: (
28+
// Top bubbles
29+
(location: 'top', type: 1, size: 10%, pos0: ( 5% 90%), pos50: ( 0% 80%), pos100: ( 0% 70%)),
30+
(location: 'top', type: 2, size: 20%, pos0: (10% 90%), pos50: ( 0% 20%), pos100: ( 0% 10%)),
31+
(location: 'top', type: 1, size: 15%, pos0: (10% 90%), pos50: (10% 40%), pos100: (10% 30%)),
32+
(location: 'top', type: 1, size: 20%, pos0: (15% 90%), pos50: (20% 0%), pos100: (20% -10%)),
33+
(location: 'top', type: 3, size: 18%, pos0: (25% 90%), pos50: (30% 30%), pos100: (30% 20%)),
34+
(location: 'top', type: 1, size: 10%, pos0: (25% 90%), pos50: (22% 50%), pos100: (22% 40%)),
35+
(location: 'top', type: 1, size: 15%, pos0: (40% 90%), pos50: (50% 50%), pos100: (50% 40%)),
36+
(location: 'top', type: 1, size: 10%, pos0: (55% 90%), pos50: (65% 20%), pos100: (65% 10%)),
37+
(location: 'top', type: 1, size: 18%, pos0: (70% 90%), pos50: (90% 30%), pos100: (90% 20%)),
38+
// Bottom bubbles
39+
(location: 'bottom', type: 1, size: 15%, pos0: (10% -10%), pos50: ( 0% 80%), pos100: ( 0% 90%)),
40+
(location: 'bottom', type: 1, size: 20%, pos0: (30% 10%), pos50: ( 20% 80%), pos100: ( 20% 90%)),
41+
(location: 'bottom', type: 3, size: 18%, pos0: (55% -10%), pos50: ( 45% 60%), pos100: ( 45% 70%)),
42+
(location: 'bottom', type: 1, size: 20%, pos0: (70% -10%), pos50: ( 60% 100%), pos100: ( 60% 110%)),
43+
(location: 'bottom', type: 1, size: 15%, pos0: (85% -10%), pos50: ( 75% 70%), pos100: ( 75% 80%)),
44+
(location: 'bottom', type: 1, size: 10%, pos0: (70% -10%), pos50: ( 95% 60%), pos100: ( 95% 70%)),
45+
(location: 'bottom', type: 1, size: 20%, pos0: (70% 0%), pos50: (105% 0%), pos100: (110% 10%))
46+
);
47+
48+
// Initialize empty lists
49+
$topBubblesImages: ();
50+
$topBubblesSizes: ();
51+
$topBubblesPos0s: ();
52+
$topBubblesPos50s: ();
53+
$topBubblesPos100s: ();
54+
$bottomBubblesImages: ();
55+
$bottomBubblesSizes: ();
56+
$bottomBubblesPos0s: ();
57+
$bottomBubblesPos50s: ();
58+
$bottomBubblesPos100s: ();
59+
60+
@each $bubble in $bubbles {
61+
$location: map.get($bubble, location);
62+
$type: map.get($bubble, type);
63+
$size: sizeCalc(map.get($bubble, size));
64+
$pos0: map.get($bubble, pos0);
65+
$pos50: map.get($bubble, pos50);
66+
$pos100: map.get($bubble, pos100);
67+
68+
@if $location == 'top' {
69+
$topBubblesImages: list.append($topBubblesImages, map.get($bubbleTypes, $type), comma);
70+
$topBubblesSizes: list.append($topBubblesSizes, ($size $size), comma);
71+
$topBubblesPos0s: list.append($topBubblesPos0s, $pos0, comma);
72+
$topBubblesPos50s: list.append($topBubblesPos50s, $pos50, comma);
73+
$topBubblesPos100s: list.append($topBubblesPos100s, $pos100, comma);
74+
} @else if $location == 'bottom' {
75+
$bottomBubblesImages: list.append($bottomBubblesImages, map.get($bubbleTypes, $type), comma);
76+
$bottomBubblesSizes: list.append($bottomBubblesSizes, ($size $size), comma);
77+
$bottomBubblesPos0s: list.append($bottomBubblesPos0s, $pos0, comma);
78+
$bottomBubblesPos50s: list.append($bottomBubblesPos50s, $pos50, comma);
79+
$bottomBubblesPos100s: list.append($bottomBubblesPos100s, $pos100, comma);
80+
}
81+
}
82+
83+
.sf-bubble-button {
84+
position: relative;
85+
86+
// Transition the scale down effect of button press
87+
transition: transform linear 50ms;
88+
89+
&:active {
90+
transform: scale(0.95);
91+
box-shadow: 0 2px 12px -5px $bubbleColor;
92+
}
93+
94+
// Inner span
95+
.sf-bubble-button-elements {
96+
position: absolute;
97+
top: 0;
98+
bottom: 0;
99+
left: 0;
100+
right: 0;
101+
102+
&:before,
103+
&:after {
104+
position: absolute;
105+
content: '';
106+
display: block;
107+
height: 100%;
108+
left: -20%;
109+
right: -20%;
110+
111+
// Needed to make the padding increase the height as the width increases.
112+
// Long buttons need more height for the animation so the circles don't get cut off.
113+
box-sizing: content-box;
114+
padding-top: 30%; // Percentage of width of containing element
115+
116+
z-index: -1;
117+
background-repeat: no-repeat;
118+
}
119+
120+
&:before {
121+
display: none;
122+
bottom: 65%;
123+
background-image: $topBubblesImages;
124+
background-size: $topBubblesSizes;
125+
}
126+
127+
&:after {
128+
display: none;
129+
top: 65%;
130+
background-image: $bottomBubblesImages;
131+
background-size: $bottomBubblesSizes;
132+
}
133+
134+
&.sf-bubble-animate {
135+
&:before {
136+
display: block;
137+
animation: emit-bubbles-top ease-out $animationDuration forwards;
138+
}
139+
&:after {
140+
display: block;
141+
animation: emit-bubbles-bottom ease-out $animationDuration forwards;
142+
}
143+
}
144+
}
145+
}
146+
147+
@keyframes emit-bubbles-top {
148+
0% {
149+
background-position: $topBubblesPos0s;
150+
}
151+
50% {
152+
background-position: $topBubblesPos50s;
153+
}
154+
100% {
155+
background-position: $topBubblesPos100s;
156+
background-size: 0% 0%;
157+
}
158+
}
159+
160+
@keyframes emit-bubbles-bottom {
161+
0% {
162+
background-position: $bottomBubblesPos0s;
163+
}
164+
50% {
165+
background-position: $bottomBubblesPos50s;
166+
}
167+
100% {
168+
background-position: $bottomBubblesPos100s;
169+
background-size: 0% 0%;
170+
}
171+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import '!style-loader!css-loader!sass-loader!./bubble-button.scss';
2+
import { MatButtonModule } from '@angular/material/button';
3+
import { componentWrapperDecorator, Meta, moduleMetadata, StoryObj } from '@storybook/angular';
4+
import { BubbleButtonDirective } from './bubble-button.directive';
5+
6+
export default {
7+
title: 'Misc/Bubble Button',
8+
9+
decorators: [
10+
moduleMetadata({
11+
imports: [MatButtonModule],
12+
declarations: [BubbleButtonDirective]
13+
}),
14+
componentWrapperDecorator(
15+
story => `
16+
<div style="display:flex; justify-content:center; padding:100px 0">
17+
${story}
18+
</div>
19+
`
20+
)
21+
],
22+
parameters: {
23+
chromatic: { disableSnapshot: true }
24+
}
25+
} as Meta;
26+
27+
type Story = StoryObj;
28+
29+
export const MatRaisedButton: Story = {
30+
render: () => ({ template: `<button mat-raised-button sfBubbleButton color="primary">Mat raised button</button>` })
31+
};
32+
33+
export const MatFlatButton: Story = {
34+
render: () => ({ template: `<button mat-flat-button sfBubbleButton color="primary">Mat flat button</button>` })
35+
};
36+
37+
export const MatStrokedButton: Story = {
38+
render: () => ({ template: `<button mat-stroked-button sfBubbleButton color="primary">Mat stroked button</button>` })
39+
};
40+
41+
export const MatButton: Story = {
42+
render: () => ({ template: `<button mat-button sfBubbleButton color="primary">Mat button</button>` })
43+
};
44+
45+
export const VanillaButton: Story = {
46+
render: () => ({ template: `<button sfBubbleButton color="primary">Vanilla button</button>` })
47+
};
48+
49+
export const LongTextButton: Story = {
50+
render: () => ({
51+
template: `<button mat-flat-button sfBubbleButton color="primary">This mat flat button has long text</button>`
52+
})
53+
};
54+
55+
export const ShortTextButton: Story = {
56+
render: () => ({ template: `<button mat-flat-button sfBubbleButton color="primary">Ok</button>` })
57+
};

0 commit comments

Comments
 (0)