유성처럼 별이 떨어지는 배경 화면을 적용해 봤다.
글 쓰다가 날아가서 다시.. 쓰는......
일단 유성이 떨어지려면 별부터 만들어야 했다. 내가 생각한 방식은 div 같은 요소에 border-radius를 줘서 작은 원 형태로 만들고, box-shadow
를 적용하여 별의 반짝임을 표현하고자 했다.
.star {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: white;
box-shadow: 0px 0px 10px #c77eff;
}
하지만 요소의 크기가 작아서 shadow가 거의 티가 나지 않았다. 이를 해결하려고 box-shadow에 대해서 더 찾아보던 중
mdn을 읽다가 box-shadow에 spread-radius
라는 값을 줄 수 있다는 사실을 알게 되었다.
spread-redius
는 그림자의 확산 범위를 지정해 줄 수 있는 값이다.
이를 이용해서 그림자의 확산 범위를 좁게 하여 그림자의 밝기를 올려줬다.
유성의 꼬리는 가상 선택자를 이용해서 구현했다. 유성의 꼬리는 보통 별에서 멀어질 수록 점점 옅어져야 하기 때문에
linear-gradient
속성을 주어서 점점 멀어지게 구현했다.
.star::after {
position: absolute;
top: calc(50% - 1px);
left: -950%;
width: 2000%; /* star의 크기에 따라서 꼬리의 길이도 다르게 해주고 싶어서 부모 요소의 길이에 비례하게 값 전달*/
height: 2px;
background: linear-gradient(to left, #fff0, #eee); /*서서히 흐려지는 꼬리*/
content: "";
transform: rotateZ(-45deg) translateX(50%);
}
그리고 유성이 대각선으로 떨어지는 효과를 주기 위해서 rotateZ값을 이용해 회전시켜 주고 회전한 만큼 translateX 값으로 위치를 조정했다.
유성이 떨어지는 효과는 animation을 이용해서 top값을 증가시켜서 떨어지게 만들고, translateX를 이용해서 대각선으로 흐르게 하면 되겠다 생각했다.
@keyframes meteor {
0% {
top: -10vh;
transform: translateX(0px);
opacity: 1;
}
100% {
top: 110vh;
transform: translateX(-120vh);
opacity: 1;
}
}
/* 처음에는 translateX가 아니라 left를 이용해서 키프레임을 적용했는데 이렇게 하니 시작 위치를 랜덤하게 잡는데 인라인으로 작성하기 불편해서 수정했다.*/
` .star {
opacity: 0;
animation: meteor 1s 1s infinite;
}
이 때 top이 이동하는 거리와 left가 이동하는 거리의 비율과 유성 꼬리의 각도가 중요하다. 삼각형의 탄젠트 값을 이용해서 tan (꼬리각도)˚ 의 값과 top,left의 이동 거리의 비가 같아야 한다.
지금 꼬리의 각도는 45도 이기 때문에 tan 45˚ = 1 이고, top의 이동거리와 left의 이동 거리의 비를 1로 맞췄다.
하지만 이대로 별들의 개수를 늘려 봤자 별들이 같은 위치에서 같은 속도로 떨어지기 때문에 자연스럽지 못하다.
그래서 별마다 left값, animation의 delay와 duration을 랜덤하게 줘서 별들이 자연스럽게 제각각으로 떨어지는 효과를 주었다.
export default function MeteorEffect({ count = 6 }: MeteorEffectProps) {
const starCount = count < MAX_STAR_COUNT ? count : MAX_STAR_COUNT;
const [starInterval, setStarInterval] = useState < number > 0; // 별들의 최소 간격
//
useEffect(() => {
const calcStarInterval = () => {
let innerWidth = window.innerWidth;
setStarInterval(Math.floor((innerWidth * 1.5) / (count * 5))); // window.innerWidth값을 별의 개수 의 배수로 나눠서 최소 간격을 지정
// 화면 밖에서 떨어지기 시작하는 별을 위해서 innerWidth에 1.5배를 해줬다.
};
calcStarInterval();
window.addEventListener("resize", calcStarInterval); // resize로 innerWidth값이 변경 되었을 때 별 간격을 다시 계산하기 위한 resize 이벤트 핸들러
return () => {
window.removeEventListener("resize", calcStarInterval);
};
}, []);
return (
<MeteorEffectLayout>
{new Array(starCount).fill(0).map((e, idx) => {
const left = `${Math.random() * count * 5 * starInterval}px`;
const animationDelay = `${Math.random() * 15}s`;
const animationDuration = `${2 + Math.random() * 4}s`;
return (
<div
key={idx}
style={{ left, animationDelay, animationDuration }}
className="star"
></div>
);
})}
</MeteorEffectLayout>
);
}
각각 별들에 inline style로 left, delay, duration을 전달했다
0 ~ 최대 딜레이 시간
사이로 지정이제 랜덤하게 별들이 떨어진다!
이후에 인라인 스타일로 색상과 크기를 랜덤하게, 별이 떨어지는 방향과 별의 각도, 속도, 딜레이는 매개변수로 받아서 적용할 수 있도록 수정하였다.
import React, { useEffect, useLayoutEffect, useState } from 'react'
import styled, { keyframes } from 'styled-components'
const MeteorKeyframe = (direction : "left" | "right", angle : number) => keyframes`
0% {
top: -10vh;
transform: translateX(0px);
opacity: 1;
}
100% {
top: 110vh;
transform: translateX(${direction ==="left" ? "-" : "+"}${120 / Math.tan((angle * Math.PI)/ 180)}vh);
opacity: 1;
}
`
interface MeteorLayoutProps {
$direction: "left" | "right";
$angle: number;
}
const MeteorEffectLayout = styled.div<MeteorLayoutProps>`
position: absolute;
top:0;
left:0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: black;
.star {
position: relative;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: white;
animation: ${(props) => MeteorKeyframe(props.$direction,props.$angle)} 4s ease-in infinite;
opacity: 0;
}
.star::after {
position: absolute;
top: calc(50% - 1px);
left: -950%;
width: 2000%;
height: 2px;
background: linear-gradient(to left, #fff0,#ffffff);
content: "";
transform: ${(props)=> props.$direction === "left" ? `rotateZ(-${props.$angle}deg)` : `rotateZ(-${180 - props.$angle}\deg)`} translateX(50%);
}
.star:nth-child(2){
transform: translateX(300px);
animation-delay: 5.1s;
}
.star:nth-child(3) {
transform: translateX(450px);
animation-delay: 1s;
}
`
interface MeteorEffectProps {
count?: number;
white?: boolean;
maxDelay?: number;
minSpeed?: number;
maxSpeed?: number;
angle?: number;
direction?: "left" | "right";
}
const MAX_STAR_COUNT = 50;
const colors = ["#c77eff", "#f6ff7e", "#ff8d7e", "#ffffff"];
export default function MeteorEffect({ count = 12, white = false, maxDelay = 15, minSpeed = 2 , maxSpeed = 4, angle = 30, direction = "right" }: MeteorEffectProps) {
const starCount = count < MAX_STAR_COUNT ? count : MAX_STAR_COUNT;
const [starInterval, setStarInterval] = useState<number>(0);
useEffect(() => {
const calcStarInterval = () => {
let innerWidth = window.innerWidth;
setStarInterval(Math.floor((innerWidth * 1.5) / (count * 5)));
}
calcStarInterval();
window.addEventListener("resize", calcStarInterval);
return () => {
window.removeEventListener("resize", calcStarInterval);
}
}, [])
return (
<MeteorEffectLayout $direction={direction} $angle={angle}>
{(new Array(starCount)).fill(0).map((e, idx) => {
const left = direction === "left" ? `${Math.random() * count * 5 * starInterval}px` : `${window.innerHeight - Math.random() * count * 5 * starInterval}px`;
const animationDelay = `${Math.random() * maxDelay}s`;
const animationDuration = maxSpeed > minSpeed ? `${minSpeed + Math.random() * maxSpeed}s` : `${2 + Math.random() * 4}`;
const colorIndex = Math.floor(Math.random() * colors.length - 0.001); // 별 색상
const size = `${2 + Math.floor(Math.random() * 5)}px`; // 별 크기
const boxShadow = `0px 0px 10px 3px ${colors[colorIndex]}`;
return <div key={idx} style={{left, animationDelay, animationDuration, boxShadow, width : size, height: size }} className='star'></div>
})}
</MeteorEffectLayout>
)
}