CSS/Vue.js synchronize animation reset with ref value change - css

I want to create an animation that fades between 3 states. To store these states (that contain an image and some text), I've created an array of objects (uploadSteps). I increment the index of this array at a specific interval to create an animation.
My problem comes with CSS, I would like to create a fade-out/fade-in effect between each step, so that the transition is more smooth. It works most of the time but sometimes, after a refresh and for the first transition, I think the CSS animation is restarted before the state is changed. I wasn't able to reproduce it constantly so far.
What it does => Image fade-in -> Image 1 -> Image 1 fade-Out -> Image 1 -> Image 2 fade-in
What I want => Image fade-in -> Image 1 -> Image 1 fade-Out -> Image 2 fade-in
If you pay attention you can see that there's kind of a blink effect at the transition between image 1 and 2. What could I do to solve this ? Help appreciated !
PS: I've included some videos to illustrate my issue
https://streamable.com/4aigjq - Animation KO
https://streamable.com/umu3nj - Animation OK
<template>
<div class="screenshot-upload-processing-container">
<div class="image-container">
<img
ref="imageRef"
:src="
currentStep ? $requireImage(currentStep.imagePath).toString() : ''
"
:alt="currentStep.text"
class="image"
/>
</div>
<div class="centered-row">
<span ref="textRef" class="text">{{
currentStep ? currentStep.text : ''
}}</span>
</div>
</div>
</template>
<script lang="ts">
import {
computed,
defineComponent,
onBeforeUnmount,
ref,
} from '#nuxtjs/composition-api';
export default defineComponent({
name: 'ScreenshotUploadProcessing',
setup() {
const stepNumber = ref(0);
const timer = ref<NodeJS.Timeout | null>(null);
const imageRef = ref<HTMLImageElement | null>(null);
const textRef = ref<HTMLSpanElement | null>(null);
const uploadSteps = computed(() => {
return [
{
imagePath: 'image1.svg',
text: 'Text 1',
},
{
imagePath: 'image2.svg',
text: 'Text 2',
},
{
imagePath: 'image3.svg',
text: 'Text 3',
},
];
});
const resetAnimations = () => {
const image = imageRef.value;
const text = textRef.value;
if (image) {
image.style.animation = 'none';
void image.offsetHeight;
image.style.animation = '';
}
if (text) {
text.style.animation = 'none';
void text.offsetHeight;
text.style.animation = '';
}
};
const updateStepNumber = () => {
timer.value = setInterval(() => {
if (stepNumber.value === uploadSteps.value.length - 1) {
stepNumber.value = 0;
} else {
stepNumber.value++;
}
resetAnimations();
}, 3000);
};
onBeforeUnmount(() => {
clearInterval(timer.value as NodeJS.Timeout);
});
const currentStep = computed(() => uploadSteps.value[stepNumber.value]);
updateStepNumber();
return {
stepNumber,
currentStep,
imageRef,
textRef,
uploadSteps,
};
},
});
</script>
<style lang="scss" scoped>
#keyframes fade {
0%,
100% {
opacity: 0;
}
40%,
60% {
opacity: 1;
}
}
.screenshot-upload-processing-container {
display: flex;
flex-direction: column;
height: 100%;
}
.image {
animation: fade 3s linear;
}
.image-container {
display: flex;
align-items: center;
justify-content: center;
height: 27rem;
margin-top: 10.3rem;
}
.centered-row {
display: flex;
align-items: center;
justify-content: center;
margin-top: $spacing-xl;
}
.text {
#extend %text-gradient-1;
animation: fade 3s linear;
font-weight: $font-weight-medium;
}
</style>

Related

Applying CSS classes to a dynamic collection of React nodes on a consistent delay

I have a dynamically sized collection of objects being passed into a Nav component that are being mapped and rendered as buttons. I want to apply a CSS animation to each button so that they slide in from off screen one at a time when the Nav component mounts. I figured that I would set up a loop through each one that updates a boolean value inside of a corresponding state object which applies the CSS class to the button to animate it, but each time that state object is updated, all of the buttons rerender which in turn starts all of the animations over. How can I prevent these rerenders?
// Nav.jsx
import React, { useState, useEffect } from 'react';
import { Button } from '../../../components';
import './Nav.scss';
const Nav = ({ actions }) => {
const [renderStates, setRenderStates] = useState(actions.reduce((accum, val) => {
return {...accum, [val.id]: false};
}, {}));
useEffect(() => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const updateStates = async () => {
for (let i = 0; i < actions.length; i++) {
if (i > 0) {
await delay(75);
}
setRenderStates((prev) => ({
...prev,
[i]: true,
}));
};
};
updateStates();
}, [actions.length]);
return (
<div className='Nav'>
{actions.map((act) => (
<div className={`Nav__Button ${renderStates[act.id] ? 'Animate' : ''}`} key={act.id}>
<Button icon={act.icon} onClick={act.onClick} />
</div>
))}
</div>
);
};
export default Nav;
/* Nav.scss */
.Nav {
height: 100%;
width: fit-content;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
align-self: center;
padding: 1rem;
}
.Nav > * {
margin: 20% 0,
}
.Nav__Button {
margin-left: -5rem;
}
.Animate {
animation: slideInFromLeft .4s ease;
}
#keyframes slideInFromLeft {
0% {
margin-left: -5rem;
}
75% {
margin-left: .5rem;
}
100% {
margin-left: 0;
}
}
Here's a codesandbox that illustrates the problem (refresh the embedded browser to see the issue):
https://codesandbox.io/s/react-css-animations-on-timer-8mxnsz
Any help would be appreciated. Thanks.
You will need to create a from the elements inside actions.map and render a memoized version of it so that if the props do not change it will not re-render.
import { useState, useEffect, memo } from "react";
import "./styles.css";
const Test = ({ animate, label }) => {
return (
<div className={`Nav__Button ${animate ? "Animate" : ""}`}>
<button>{label}</button>
</div>
);
};
const TestMemo = memo(Test);
export default function App() {
const actions = [
{
id: 0,
label: "button 0"
},
{
id: 1,
label: "button 1"
},
{
id: 2,
label: "button 2"
},
{
id: 3,
label: "button 3"
}
];
const [renderStates, setRenderStates] = useState(
actions.reduce((accum, val) => {
return { ...accum, [val.id]: false };
}, {})
);
useEffect(() => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const updateStates = async () => {
for (let i = 0; i < actions.length; i++) {
if (i > 0) {
await delay(2000);
}
setRenderStates((prev) => ({
...prev,
[i]: true
}));
}
};
updateStates();
}, [actions.length]);
return (
<div className="App">
{actions.map((act) => (
<TestMemo animate={renderStates[act.id]} label={act.label} />
))}
</div>
);
}

React component with state triggers animation in subcomponent when the state of parent component is changed

ProjectsSection component renders a document section with header and cards. A header animation should be triggered when the header is visible on the screen for the 1st time and run only once. However when I added a state to the ProjectsSection component with every state change a header animation runs and what is surprising to me only a part of it (letters/ spans that are children to h2 move, but brackets/ pseudoelements on h2 don't). In my project I use css modules for styling.
I have tried to wrap SectionHeader component in React.memo but it does not help.
Main component:
const ProjectsSection = () => {
const [openProjectCardId, setOpenProjectCardId] = useState('');
return (
<section id="projects" className="section">
<div className="container">
<SectionHeader>Projects</SectionHeader>
<div className={styles.content_wrapper}>
{projects.map((project, ind) => (
<Project
key={project.id}
project={project}
ind={ind}
isOpen={openProjectCardId === project.id}
setOpenProjectCardId={setOpenProjectCardId}
/>
))}
</div>
</div>
</section>
);
};
SectionHeader component:
const SectionHeader = ({ children }) => {
const headerRef = useIntersection(
styles.sectionHeader__isVisible,
{ rootMargin: '0px 0px -100px 0px', threshold: 1 },
);
const textToLetters = children.split('').map((letter) => {
const style = !letter ? styles.space : styles.letter;
return (
<span className={style} key={nanoid()}>
{letter}
</span>
);
});
return (
<div className={styles.sectionHeader_wrapper} ref={headerRef}>
<h2 className={styles.sectionHeader}>{textToLetters}</h2>
</div>
);
};
Css
.sectionHeader_wrapper {
position: relative;
// other properties
&::before {
display: block;
content: ' ';
position: absolute;
opacity: 0;
// other properties
}
&::after {
display: block;
content: ' ';
position: absolute;
opacity: 0;
// other properties
}
}
.sectionHeader {
position: relative;
display: inline-block;
overflow: hidden;
// other properties
}
.letter {
position: relative;
display: inline-block;
transform: translateY(100%) skew(0deg, 20deg);
}
.sectionHeader__isVisible .letter {
animation: typeletter .25s ease-out forwards;
}
useIntersection hook
const useIntersection = (
activeClass,
{ root = null, rootMargin = '0px', threshold = 1 },
dependency = [],
unobserveAfterFirstIntersection = true
) => {
const elementRef = useRef(null);
useEffect(() => {
const options: IntersectionObserverInit = {
root,
rootMargin,
threshold,
};
const observer = new IntersectionObserver((entries, observerObj) => {
entries.forEach((entry) => {
if (unobserveAfterFirstIntersection) {
if (entry.isIntersecting) {
entry.target.classList.add(activeClass);
observerObj.unobserve(entry.target);
}
} else if (entry.isIntersecting) {
entry.target.classList.add(activeClass);
} else {
entry.target.classList.remove(activeClass);
}
});
}, options);
// if (!elementRef.current) return;
if (elementRef.current) {
observer.observe(elementRef.current);
}
}, [...dependency]);
return elementRef;
};
I have found a solution to this problem.
This was apparently about textToLetters variable created every time the state of parent component was changing.
I used useMemo to keep this value and now animation is not triggered anymore.
const textToLetters = (text) =>
text.split('').map((letter) => {
const style = !letter ? styles.space : styles.letter;
return (
<span className={style} key={nanoid()}>
{letter}
</span>
);
});
const SectionHeader= ({ children }) => {
const headerRef = useIntersection(
styles.sectionHeader__isVisible,
{ rootMargin: '0px 0px -100px 0px', threshold: 1 },
[children]
);
const letters = React.useMemo(() => textToLetters(children), [children]);
return (
<div className={styles.sectionHeader_wrapper} ref={headerRef}>
<h2 className={styles.sectionHeader}>{letters}</h2>
</div>
);
};

Why animation works only for the first slide and not for the others?

I am trying to make a simple slideshow with fade in animation using react hooks. But my animation works only on first slide and not for others.
This is function code:
import React, { useEffect, useRef, useState } from "react";
import './Homepage.css';
const Homepage = () => {
const slides = [
"../../backgroundImages/1a.jpg",
"../../backgroundImages/1b.jpg",
"../../backgroundImages/1.jpg"
];
const playRef = useRef<any>(null);
const [slideIndex, setSlideIndex] = useState<number>(0);
useEffect(() => {
playRef.current = nextSlide;
});
useEffect(() => {
const play = setInterval(() => {
playRef.current();
}, 3000);
return () => {
clearInterval(play);
}
}, []);
const nextSlide = () => {
slideIndex >= slides.length -1
? setSlideIndex(0)
: setSlideIndex((slideIndex) => slideIndex + 1);
}
return(
<img alt='background' src={slides[slideIndex]}
className='homepage-background-div'
/>
);
}
export default Homepage;
and this is css:
.homepage-background-div {
position: absolute;
top: 0;
height: 100vh;
width: 100vw;
z-index: -1;
animation: fadein 2s;
}
#keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
I have red answers for similar questions here on stackoverflow as well as many others articles and tutorials but was not able to solve the issue. Any help will be more than welcomed.
add key={slides[slideIndex]} to tag img
<img
alt="background"
key={slides[slideIndex]}
src={slides[slideIndex]}
className="homepage-background-div"
/>

How should I loop a 360 degree image in CSS for an Ionic App?

I have been asked to implement some of our companies 360 degree photos (panorama sort of things) on to our company app.
So far I have only been able to get the image to go across then back again which doesn't give the smooth endless photo loop we are after.
I am using Ionic 4.
Here is my current CSS code
#keyframes example {
from {
left: 0;
}
to {
left: -1350px;
}
}
.three-sixty {
max-width: none !important;
position: absolute;
height: 250px;
animation-name: example;
animation-duration: 10s;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: linear;
}
<ion-col>
<img class="three-sixty" [src]="mainImage">
</ion-col>
I am assuming I need multiple of the same image with timed animations to achieve the endless loop?
Extra Info:
The start of the image fits perfectly with the end of the image - so I need to make sure the image runs on smoothly from the last.
Any help would be greatly appreciated.
Please check the following component I implemented to view a 360 image in ionic (for web and mobile) in the latest version of ionic:
SCSS:
.rotatable-image {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background-color: lightgrey;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
}
HTML:
<div
class="rotatable-image"
(mousedown)="handleMouseDown($event)"
(ondragstart)="preventDragHandler($event)"
>
<img
draggable="false"
class="rotatable-image"
alt=""
[src]="_dir + '/' + imageIndex + '.jpg'"
/>
<div style="font-size: 50px">
{{ imageIndex }}
</div>
</div>
TS:
import {
AfterViewInit,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
} from '#angular/core';
// You can play with this to adjust the sensitivity
// higher values make mouse less sensitive
const pixelsPerDegree = 3;
#Component({
selector: 'app-panoramic',
templateUrl: './panoramic.component.html',
styleUrls: ['./panoramic.component.scss'],
})
export class PanoramicComponent implements AfterViewInit, OnDestroy {
// tslint:disable-next-line: variable-name
_dir = '../../../assets/dummies/panoramic';
// tslint:disable-next-line: variable-name
_totalImages = 46;
#Input()
set dir(val: string) {
this._dir = val !== undefined && val !== null ? val : '';
}
#Input()
set totalImages(val: number) {
this._totalImages = val !== undefined && val !== null ? val : 0;
}
dragging = false;
dragStartIndex = 1;
dragStart?: any;
imageIndex = 1;
ngAfterViewInit() {
document.addEventListener(
'mousemove',
(evt: any) => {
this.handleMouseMove(evt);
},
false
);
document.addEventListener(
'mouseup',
() => {
this.handleMouseUp();
},
false
);
}
ngOnDestroy() {
document.removeEventListener(
'mousemove',
(evt: any) => {
this.handleMouseMove(evt);
},
false
);
document.removeEventListener(
'mouseup',
() => {
this.handleMouseUp();
},
false
);
}
handleMouseDown(e: any) {
this.dragging = true;
console.log(e.screenX);
this.dragStart = e.screenX;
this.dragStartIndex = this.imageIndex;
}
handleMouseUp() {
this.dragging = false;
}
updateImageIndex(currentPosition: any) {
const numImages = this._totalImages;
const pixelsPerImage = pixelsPerDegree * (360 / numImages);
// pixels moved
const dx = (currentPosition - this.dragStart) / pixelsPerImage;
let index = Math.floor(dx) % numImages;
if (index < 0) {
index = numImages + index - 1;
}
index = (index + this.dragStartIndex) % numImages;
if (index !== this.imageIndex) {
if (index === 0) {
index = 1;
}
this.imageIndex = index;
}
}
handleMouseMove(e: any) {
if (this.dragging) {
this.updateImageIndex(e.screenX);
}
}
preventDragHandler(e: any) {
e.preventDefault();
}
}

Having trouble getting rid of the blue highlight

I've been working on a section with expandable/collapsible sections. When I click on a section to expand or collapse it, a blue focus area shows up but it is placed on a weird angle. I don't know what is causing it and would like a solution to either get rid of it or place it back at the normal horizontal angle. Does anybody have any suggestions as to how to fix this?
I am using a Macbook and Chrome browser.
The entire grey block that this component appears in is placed at an angle as you can see from the top of the image attached below but in the reverse direction from the highlighted focus area.
My css:
#import '../../theme/variables.css';
.rotatedSection {
padding-bottom: 2rem;
}
.container {
max-width: 64rem;
margin: 0 auto;
display: flex;
padding: 2rem 0;
#media screen and (max-width: 68rem) {
margin: 0 3rem;
}
}
.accordianContainer {
flex: 1;
margin-right: 2rem;
min-width: 500px;
#media screen and (max-width: $tablet-lg-max-width) {
margin-right: 0;
}
#media screen and (max-width: 900px) {
min-width: 0;
}
}
.imageContainer {
flex: 1;
margin-left: 2rem;
max-height: 300px;
display: flex;
justify-content: center;
img {
flex: 1;
}
#media screen and (max-width: $tablet-lg-max-width) {
margin-left: 0;
}
}
.heading {
composes: h2 from 'theme/text';
margin-left: auto;
margin-right: auto;
}
My react code:
import React, {Component, PropTypes} from 'react';
import RotatedSection from 'components/RotatedSection';
import AccordionItem from './AccordionItem';
import css from './styles.css';
class AccordionSectionWithImage extends Component {
constructor (props) {
super(props);
this.state = {
activeIndex: null,
};
this.onOpen = this.onOpen.bind(this);
this.onClose = this.onClose.bind(this);
this.setActive = this.setActive.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this);
}
onOpen = (index) => {
this.setActive(index);
};
onClose = (callback = () => null) => {
this.setActive(null);
callback();
};
setActive = (activeIndex) => this.setState({activeIndex});
handleClickOutside = () => this.props.collapseOnBlur && this.onClose();
render () {
const {
entry: {
items,
heading,
image,
},
showIndex,
classNames,
meta = {},
} = this.props;
const {routeParams, toggleHamburger} = meta;
const {activeIndex} = this.state;
return (
<RotatedSection color='whisper' className={css.rotatedSection}>
<div className={css.container}>
<div className={css.accordianContainer}>
<h2 className={css.heading}>{heading}</h2>
{items && items.map((item, index) => (
<AccordionItem
key={index}
showIndex={showIndex}
entry={item}
meta={{
position: index,
isOpen: (index === activeIndex),
onOpen: () => this.onOpen(index),
onClose: () => this.onClose(),
onChildClick: () => this.onClose(toggleHamburger),
routeParams,
}}
classNames={classNames}
/>
))}
</div>
<div className={css.imageContainer}>
<img src={image && image.fields && image.fields.file.url} alt='Educational assessment' />
</div>
</div>
</RotatedSection>
);
}
}
AccordionSectionWithImage.propTypes = {
meta: PropTypes.object,
entry: PropTypes.object,
collapseOnBlur: PropTypes.bool,
showIndex: PropTypes.bool,
classNames: PropTypes.object,
};
export default AccordionSectionWithImage;
React component for individual section:
function AccordionItem (props) {
const {
meta: {
isOpen,
onOpen,
onClose,
},
entry: {
heading,
text,
},
} = props;
const handleClick = () => (isOpen ? onClose() : onOpen());
return (
<div className={css.itemContainer}>
<div className={css.innerContainer}>
<h3 className={css.heading} onClick={handleClick}>
<span className={css.titleText}>{heading}</span>
<i className={`zmdi zmdi-plus ${css.titleToggle}`} />
</h3>
{isOpen && (
<div className={css.contents}>
{text}
</div>
)}
</div>
</div>
);
}
For anybody else experiencing a similar problem:
Problem only appeared on mobile phones and the device mode of chrome inspector. It was due to the tap-highlight property.
Setting -webkit-tap-highlight-color to rgba(0,0,0,0) hid the problem but it's a non standard css property so the solution may not work for all devices/browsers/users.

Resources