[해피CGI][cgimall] GIF 없이 구현하는 동적 애니메이션 버튼
이미지 애니메이션 파일을 사용하지 않고 HTML과 CSS 효과를 활용해 동적인 버튼을 구현했습니다.
마우스를 올리거나 클릭하면 버튼 내부의 그래픽 요소와 배경이 자연스럽게 움직입니다.
문구, 색상, 크기, 애니메이션 속도를 코드에서 간편하게 수정할 수 있습니다.
이벤트 페이지, 신청 화면, 상품 상세 페이지 등 강조가 필요한 버튼에 활용하기 좋습니다.

HTML 구조
<div id="root"></div>
CSS 소스
* {
border: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--hue: 210;
--gray50: hsl(var(--hue) 10% 95%);
--gray950: hsl(var(--hue) 10% 5%);
--primary500: hsl(var(--hue) 90% 45%);
--trans-dur: 0.3s;
color-scheme: light dark;
font-size: clamp(1.5rem, 1.425rem + 0.375vw, 1.875rem);
}
body,
button {
font: 1em / 1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: light-dark(var(--gray50), var(--gray950));
color: light-dark(var(--gray950), var(--gray50));
display: grid;
place-items: center;
height: 100vh;
transition:
background-color var(--trans-dur),
color var(--trans-dur);
}
button {
-webkit-tap-highlight-color: transparent;
}
.plane-btn {
background-color: var(--primary500);
border-radius: 0.5em;
color: var(--gray50);
cursor: pointer;
display: block;
overflow: hidden;
padding: 0.5em 1.5em;
position: relative;
transition:
background-color var(--trans-dur),
color var(--trans-dur),
transform var(--trans-dur);
&__cloud {
background-color: hsl(0 0% 100%);
border-radius: 50%;
display: block;
filter: blur(16px);
opacity: 0.7;
height: 2.25em;
&--l {
transform: translateX(-33%);
}
&--r {
margin-left: auto;
transform: translateX(33%);
}
&--sm {
width: 33%;
}
&--md {
width: 50%;
}
&--lg {
width: 67%;
}
}
&__land {
background-color: hsl(120 30% 50%);
border-radius: 33% 67% 70% 30% / 33% 67% 33% 67%;
box-shadow: 0 0 0 0.5em hsla(0 0% 100% / 0.1);
width: 3.5em;
height: 2.25em;
transform: translateX(-25%);
}
&__plane,
&__scroller {
position: absolute;
}
&__plane,
&__text {
transition: transform 0.5s ease-in-out;
}
&__plane {
display: grid;
place-items: center;
font-size: 1.25em;
inset: 0;
transform: translateY(100%);
&::before {
animation: 1.5s plane-move ease-in-out infinite;
content: "✈️";
display: block;
}
}
&__scroller {
display: flex;
flex-direction: column;
inset: 0 0 auto;
padding: 3em 0.75em;
transform: translateY(-100%);
}
&__text {
display: block;
}
&:focus-visible,
&:hover {
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
&:focus-visible &__plane,
&:hover &__plane {
transform: translateY(0);
}
&:focus-visible &__scroller,
&:hover &__scroller {
animation: 3s scroller-move linear infinite;
}
&:focus-visible &__text,
&:hover &__text {
transform: translateY(-2.5em);
}
}
// Animations
@keyframes plane-move {
0%,
100% {
transform: translateX(-0.125rem) rotate(-45deg);
}
50% {
transform: translateX(0.125rem) rotate(-45deg);
}
}
@keyframes scroller-move {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(0);
}
}
JS 소스
import React, { StrictMode } from "https://esm.sh/react";
import { createRoot } from "https://esm.sh/react-dom/client";
import clsx from "https://esm.sh/clsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AirplaneTicketButton />
</StrictMode>
);
interface AirplaneTicketButtonProps {
onclick?: () => void;
}
function AirplaneTicketButton({
onclick
}: Readonly<AirplaneTicketButtonProps>) {
return (
<button
className="plane-btn"
type="button"
onClick={onclick}>
<span className="plane-btn__scroller">
<AirplaneTicketButtonLand />
</span>
<span className="plane-btn__scroller">
<AirplaneTicketButtonCloud position="r" size="lg" />
<AirplaneTicketButtonCloud />
<AirplaneTicketButtonCloud size="sm" />
<AirplaneTicketButtonCloud position="r" size="lg" />
</span>
<span className="plane-btn__plane" aria-hidden="true"></span>
<span className="plane-btn__text">Get Tickets</span>
</button>
);
}
interface AirplaneTicketButtonCloudProps {
position?: "l" | "r";
size?: "sm" | "md" | "lg";
}
function AirplaneTicketButtonCloud({
position = "l",
size = "md"
}: Readonly<AirplaneTicketButtonCloudProps>) {
const _position = `plane-btn__cloud--${position}`;
const _size = `plane-btn__cloud--${size}`;
return (
<span className={clsx("plane-btn__cloud", _position, _size)}></span>
);
}
interface AirplaneTicketButtonLandProps {
position?: "l" | "r";
}
function AirplaneTicketButtonLand({
position = "l"
}: Readonly<AirplaneTicketButtonLandProps>) {
const _position = `plane-btn__land--${position}`;
return (
<span className={clsx("plane-btn__land", _position)}></span>
);
}
