Skip to content

Commit

Permalink
flip-card | Customizable animations via WAAPI
Browse files Browse the repository at this point in the history
  • Loading branch information
Auroratide committed Feb 16, 2024
1 parent 1ec24e3 commit 273fa2e
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 47 deletions.
83 changes: 78 additions & 5 deletions components/flip-card/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ flip-card::part(edge) {

* Use the `border-radius` CSS property to make rounded corners, but it must be a single absolute length.
* The `--corner-granularity` CSS property is an integer that represents how smooth the 3D curve is on a card's rounded corners. Higher is smoother; default is `4`.
* Call `recreateBorderRadius()` anytime the card's sizes or border-radius's sizes change.

`border-radius` on the card rounds the corners (mostly) like you would expect. It's a bit of a magic trick to make the card's edge rounded, as curved 3D surfaces don't exist in HTML/CSS. As a result there are some limitations.

Expand Down Expand Up @@ -199,6 +200,8 @@ flip-card {
}
```

Finally, there is an escape hatch! If you find yourself dynamically changing the dimensions of the card or its border radius, you can manually call the `recreateBorderRadius()` method to reformat the corners. It's up to you to decide when this is needed, but if you have an unchanging card, then it probably isn't needed at all.

### Flip height and duration

The following apply to the default animation.
Expand Down Expand Up @@ -295,7 +298,81 @@ As such, to get the most realistic card flips when there are multiple cards, you

### Fully custom animations

TODO
You can use the `setFlipToFrontAnimation()` and `setFlipToBackAnimation()` methods in Javascript to give the card a different flip animation.

Here's an example for a vertical flip, rather than a horizontal flip.

<!--DEMO
<wc-demo class="flip-card-demo">
<flip-card class="default vertical-flip">
<section slot="front">
<p>The front!</p>
</section>
<section slot="back">
<p>The back!</p>
</section>
</flip-card>
<div slot="actions">
<button>Flip!</button>
</div>
</wc-demo>
/DEMO-->

```js
card.setFlipToFrontAnimation(
[ {
transform: "translateZ(calc(-1 * var(--_depth))) rotateX(180deg)",
}, {
transform: "translateZ(var(--_height)) rotateX(270deg)",
}, {
transform: "translateZ(0em) rotateX(360deg)",
} ],
{
easing: "ease-in-out",
},
)

card.setFlipToBackAnimation(
[ {
transform: "translateZ(0em) rotateX(0deg)",
}, {
transform: "translateZ(var(--_height)) rotateX(90deg)",
}, {
transform: "translateZ(calc(-1 * var(--_depth))) rotateX(180deg)",
} ],
{
easing: "ease-in-out",
},
)
```

(and a dash of CSS too, to orient the backside properly at rest)

```css
flip-card > [slot="back"] {
transform: scale(-1);
}
```


The `flip-card` component uses the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) to perform animations, rather than CSS. This confers some advantages, such as being able to generate animations on the fly or apply easing to the entire animation rather than just keyframes. It's also necessary in order to penetrate the Shadow DOM properly.

Each method has the same interface as the Element's [animate()](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate) method:

* The first parameter is a list of keyframes. The `offset` property is equivalent to defining percentage in CSS's equivalent `@keyframes`. See [Keyframe Formats](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Keyframe_Formats) for details.
* The second parameter are options, such as number of iterations or easing. See [KeyframeEffect's object parameter](https://developer.mozilla.org/en-US/docs/Web/API/KeyframeEffect/KeyframeEffect#parameters) for details.

### All customization options in a single list

| Name | Default | Description |
| ------------- | ------------- | ------------- |
| `--card-depth` | `0.15em` | How thick the card's edge is. |
| `--flip-height` | `20em` | How high the card travels vertically when flipped. |
| `--flip-duration` | `0.75s` | How long it takes the card to complete a flip. |
| `--flip-duration` | `0.75s` | How long it takes the card to complete a flip. |
| `--corner-granularity` | `4` | How smooth the card's corner edges should be when rounded. Higher is smoother. |
| `setFlipToFrontAnimation()` | a horizontal flip | Animation to play when flipping the card from being face down to being face up. Parameters are keyframes and options. |
| `setFlipToBackAnimation()` | a horizontal flip | Animation to play when flipping the card from being face up to being face down. Parameters are keyframes and options. |

## Events

Expand Down Expand Up @@ -324,7 +401,3 @@ TODO. Test/consider:
* Labelling the card so we know it's a card with a front and a back
* what if the card contains focusable elements inside? Tabbing order?
* aria-live recommendations?

## OTHER TODO

* custom animations in the shadow dom is janky doodle
96 changes: 54 additions & 42 deletions components/flip-card/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export class FlipCardElement extends HTMLElement {
--_duration: var(--flip-duration, 0.75s);
--_height: var(--flip-height, 20em);
--_depth: var(--card-depth, 0.15em);
--_animation-front: var(--flip-to-front-animation, flip-to-front linear var(--_duration) 1 both);
--_animation-back: var(--flip-to-back-animation, flip-to-back linear var(--_duration) 1 both);
--_granularity: var(--corner-granularity, 4);
display: block;
Expand Down Expand Up @@ -85,8 +83,8 @@ export class FlipCardElement extends HTMLElement {
}
.top, .bottom {
width: calc(100% - 2 * var(--_radius));;
height: var(--flip-depth);
width: calc(100% - 2 * var(--_radius));
height: var(--_depth);
inset-inline: var(--_radius);
} .top {
inset-block-start: 0;
Expand Down Expand Up @@ -156,35 +154,9 @@ export class FlipCardElement extends HTMLElement {
transform: rotateY(-180deg);
}
@keyframes flip-to-back {
0% {
transform: translateZ(0em) rotateY(0deg);
animation-timing-function: ease-in;
} 50% {
transform: translateZ(var(--_height)) rotateY(-90deg);
animation-timing-function: ease-out;
} 100% {
transform: translateZ(calc(-1 * var(--_depth))) rotateY(-180deg);
animation-timing-function: ease-out;
}
}
@keyframes flip-to-front {
0% {
transform: translateZ(calc(-1 * var(--_depth))) rotateY(-180deg);
animation-timing-function: ease-in;
} 50% {
transform: translateZ(var(--_height)) rotateY(-270deg);
animation-timing-function: ease-out;
} 100% {
transform: translateZ(0em) rotateY(-360deg);
animation-timing-function: ease-out;
}
}
@media (prefers-reduced-motion: reduce) {
.container {
animation-duration: 0s !important;
--_duraction: 0s;
}
}
`
Expand All @@ -204,6 +176,38 @@ export class FlipCardElement extends HTMLElement {

flip() { this.facedown = !this.facedown }

#flipToFrontAnimation: AnimationDescription = {
keyframes: [ {
transform: "translateZ(calc(-1 * var(--_depth))) rotateY(-180deg)",
}, {
transform: "translateZ(var(--_height)) rotateY(-270deg)",
}, {
transform: "translateZ(0em) rotateY(-360deg)",
} ],
options: {
easing: "ease-in-out",
},
}
setFlipToFrontAnimation(keyframes: AnimationDescription["keyframes"], options?: AnimationDescription["options"]) {
this.#flipToFrontAnimation = { keyframes, options }
}

#flipToBackAnimation: AnimationDescription = {
keyframes: [ {
transform: "translateZ(0em) rotateY(0deg)",
}, {
transform: "translateZ(var(--_height)) rotateY(-90deg)",
}, {
transform: "translateZ(calc(-1 * var(--_depth))) rotateY(-180deg)",
} ],
options: {
easing: "ease-in-out",
},
}
setFlipToBackAnimation(keyframes: AnimationDescription["keyframes"], options?: AnimationDescription["options"]) {
this.#flipToBackAnimation = { keyframes, options }
}

recreateBorderRadius() {
this.#container?.style.setProperty("--_radius", getComputedStyle(this).borderRadius)
this.#createCorners()
Expand All @@ -222,16 +226,8 @@ export class FlipCardElement extends HTMLElement {

this.#setAccessibleSide(this.facedown)
this.recreateBorderRadius()

this.#container?.addEventListener("animationend", this.#onAnimationEnd)
}

disconnectedCallback() {
this.#container?.removeEventListener("animationend", this.#onAnimationEnd)
}

#onAnimationEnd = () => this.#emit(FLIPPED)

attributeChangedCallback(attribute: string, oldValue: string, newValue: string) {
this.#attributeCallbacks[attribute]?.(newValue, oldValue)
}
Expand Down Expand Up @@ -262,9 +258,18 @@ export class FlipCardElement extends HTMLElement {
}

#animate() {
if (this.#container) {
this.#container.style.animation = this.facedown ? "var(--_animation-back)" : "var(--_animation-front)"
}
const duration = cssTimeToMs(getComputedStyle(this).getPropertyValue("--_duration"))
const animationToPlay = this.facedown ? this.#flipToBackAnimation : this.#flipToFrontAnimation
const animation = this.#container?.animate(animationToPlay.keyframes, {
duration,
fill: "both",
...animationToPlay.options,
})

animation?.addEventListener("finish", () => {
animation.commitStyles()
this.#emit(FLIPPED)
}, { once: true })
}

#emit(event: string) {
Expand All @@ -290,3 +295,10 @@ export class FlipCardElement extends HTMLElement {
return root
}
}

type AnimationDescription = {
keyframes: Keyframe[] | PropertyIndexedKeyframes,
options?: Omit<KeyframeAnimationOptions, "duration">,
}

const cssTimeToMs = (time: string) => parseFloat(time) * (time.endsWith("ms") ? 1 : 1000)
30 changes: 30 additions & 0 deletions docs/src/routes/flip-card/setup-flip-card-demos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,34 @@ export const setupFlipCardDemos = () => {
cards.forEach((card) => card.flip())
})
})

customElements.whenDefined("flip-card").then(() => {
document.querySelectorAll<FlipCardElement>(".vertical-flip").forEach((card) => {
card.setFlipToFrontAnimation(
[ {
transform: "translateZ(calc(-1 * var(--_depth))) rotateX(180deg)",
}, {
transform: "translateZ(var(--_height)) rotateX(270deg)",
}, {
transform: "translateZ(0em) rotateX(360deg)",
} ],
{
easing: "ease-in-out",
},
)

card.setFlipToBackAnimation(
[ {
transform: "translateZ(0em) rotateX(0deg)",
}, {
transform: "translateZ(var(--_height)) rotateX(90deg)",
}, {
transform: "translateZ(calc(-1 * var(--_depth))) rotateX(180deg)",
} ],
{
easing: "ease-in-out",
},
)
})
})
}
4 changes: 4 additions & 0 deletions docs/src/routes/flip-card/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@ flip-card.long-and-high {
perspective: none;
}

flip-card.vertical-flip > [slot="back"] {
transform: scale(-1);
}

flip-card p:last-child { margin: 0; }

0 comments on commit 273fa2e

Please sign in to comment.