Created
April 9, 2026 15:41
-
-
Save yinheli/f2812f57a290f17f1639330387f75f6c to your computer and use it in GitHub Desktop.
Greeting animation effect - per-character blur/fade/scale transition, inspired by https://cai.im/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Greeting Animation</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| background: #fafafa; | |
| color: #1a1a1a; | |
| } | |
| .greeting-container { | |
| position: relative; | |
| height: 1.5em; | |
| display: flex; | |
| align-items: center; | |
| font-size: 3rem; | |
| font-weight: 600; | |
| } | |
| .greeting-word { | |
| position: absolute; | |
| left: 0; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| display: flex; | |
| pointer-events: none; | |
| visibility: hidden; | |
| z-index: 1; | |
| } | |
| .greeting-word.active { | |
| visibility: visible; | |
| z-index: 2; | |
| } | |
| .greeting-word.previous { | |
| visibility: visible; | |
| z-index: 1; | |
| } | |
| .greeting-char { | |
| display: inline-block; | |
| transition: all 800ms cubic-bezier(0.23, 1, 0.32, 1); | |
| opacity: 0; | |
| filter: blur(12px); | |
| transform: translateY(-10px) scale(0.9); | |
| transition-delay: 0ms; | |
| } | |
| .greeting-word.active .greeting-char { | |
| opacity: 1; | |
| filter: blur(0); | |
| transform: translateY(0) scale(1.1); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="greeting-container" id="greeting"></div> | |
| <script> | |
| const texts = ["Hello!", "Hola!", "你好!", "Bonjour!", "こんにちは!", "안녕!", "Ciao!"]; | |
| const INTERVAL = 3000; | |
| const CHAR_DELAY = 40; | |
| const container = document.getElementById("greeting"); | |
| let current = 0; | |
| let previous = -1; | |
| // Build DOM: one div per word, one span per character | |
| const wordEls = texts.map((text) => { | |
| const div = document.createElement("div"); | |
| div.className = "greeting-word"; | |
| text.split("").forEach((char) => { | |
| const span = document.createElement("span"); | |
| span.className = "greeting-char"; | |
| span.textContent = char === " " ? "\u00A0" : char; | |
| div.appendChild(span); | |
| }); | |
| container.appendChild(div); | |
| return div; | |
| }); | |
| function update() { | |
| wordEls.forEach((el, i) => { | |
| el.classList.toggle("active", i === current); | |
| el.classList.toggle("previous", i === previous); | |
| // Set staggered delay only on active word's chars | |
| const chars = el.querySelectorAll(".greeting-char"); | |
| chars.forEach((ch, ci) => { | |
| ch.style.transitionDelay = i === current ? `${ci * CHAR_DELAY}ms` : "0ms"; | |
| }); | |
| }); | |
| } | |
| update(); | |
| setInterval(() => { | |
| previous = current; | |
| current = (current + 1) % texts.length; | |
| update(); | |
| }, INTERVAL); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment