diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html
index 7049cbd4ee1f..80ccc42e1769 100644
--- a/frontend/src/html/pages/settings.html
+++ b/frontend/src/html/pages/settings.html
@@ -1022,6 +1022,7 @@
+
diff --git a/frontend/src/styles/animations.scss b/frontend/src/styles/animations.scss
index 2ddc5002d2ca..d5928cc1598c 100644
--- a/frontend/src/styles/animations.scss
+++ b/frontend/src/styles/animations.scss
@@ -173,3 +173,17 @@
color: transparent;
}
}
+
+@keyframes typedEffectTumble {
+ 0% {
+ transform: translate(0, 0) rotate(0deg);
+ opacity: 1;
+ }
+ 25% {
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(var(--fall-x), 100vh) rotate(var(--fall-rotation));
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss
index 317e16f7b67e..cd8d4692cf38 100644
--- a/frontend/src/styles/test.scss
+++ b/frontend/src/styles/test.scss
@@ -533,6 +533,18 @@
}
}
+ &.typed-effect-tumble {
+ .word.typed:not(.error) {
+ opacity: 0;
+ }
+
+ @media (prefers-reduced-motion) {
+ .word.typed:not(.error) {
+ opacity: 1;
+ }
+ }
+ }
+
&.typed-effect-dots {
/* transform already typed letters into appropriately colored dots */
@@ -602,6 +614,13 @@
}
}
+.tumble-clone {
+ animation: typedEffectTumble 1s cubic-bezier(0.5, 0, 1, 1) forwards;
+ display: inline-block;
+ pointer-events: none;
+ z-index: 1000;
+}
+
.word {
position: relative;
font-size: 1em;
diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts
index 71e3ca0fa80c..07309d0ba367 100644
--- a/frontend/src/ts/test/test-ui.ts
+++ b/frontend/src/ts/test/test-ui.ts
@@ -59,6 +59,7 @@ import * as ThemeController from "../controllers/theme-controller";
import * as ModesNotice from "../elements/modes-notice";
import * as Last10Average from "../elements/last-10-average";
import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer";
+import * as TypedEffects from "./typed-effects";
import {
ElementsWithUtils,
ElementWithUtils,
@@ -147,6 +148,7 @@ export function updateActiveElement(
if (previousActiveWord !== null) {
if (direction === "forward") {
previousActiveWord.addClass("typed");
+ TypedEffects.onWordTyped(previousActiveWord);
Ligatures.set(previousActiveWord, true);
} else if (direction === "back") {
//
@@ -164,6 +166,7 @@ export function updateActiveElement(
newActiveWord.addClass("active");
newActiveWord.removeClass("error");
newActiveWord.removeClass("typed");
+ newActiveWord.setStyle({ opacity: "" });
Ligatures.set(newActiveWord, false);
activeWordTop = newActiveWord.getOffsetTop();
@@ -495,6 +498,7 @@ function updateWordWrapperClasses(): void {
}
function showWords(): void {
+ TypedEffects.clear();
wordsEl.setHtml("");
if (Config.mode === "zen") {
@@ -1937,6 +1941,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void {
}
export function onTestFinish(): void {
+ TypedEffects.clear();
Caret.hide();
LiveSpeed.hide();
LiveAcc.hide();
@@ -2081,6 +2086,9 @@ configEvent.subscribe(({ key, newValue }) => {
"tapeMargin",
].includes(key)
) {
+ if (key === "typedEffect") {
+ TypedEffects.clear();
+ }
if (key !== "fontFamily") updateWordWrapperClasses();
if (["typedEffect", "fontFamily", "fontSize"].includes(key)) {
Ligatures.update(key, wordsEl);
diff --git a/frontend/src/ts/test/typed-effects.ts b/frontend/src/ts/test/typed-effects.ts
new file mode 100644
index 000000000000..ac03f0c39936
--- /dev/null
+++ b/frontend/src/ts/test/typed-effects.ts
@@ -0,0 +1,59 @@
+import { Config } from "../config/store";
+import * as Misc from "../utils/misc";
+import { ElementWithUtils, qsa, qsr } from "../utils/dom";
+
+const wordsEl = qsr(".pageTest #words");
+const TUMBLE_DURATION_MS = 1000;
+
+export function onWordTyped(word: ElementWithUtils): void {
+ switch (Config.typedEffect) {
+ case "tumble":
+ triggerTumble(word);
+ return;
+ default:
+ return;
+ }
+}
+
+export function clear(): void {
+ qsa(".tumble-clone").remove();
+ wordsEl.qsa(".word").setStyle({ opacity: "" });
+}
+
+function triggerTumble(word: ElementWithUtils): void {
+ if (word.hasClass("error")) return;
+ if (Misc.prefersReducedMotion()) return;
+
+ const rect = word.native.getBoundingClientRect();
+ if (rect.width === 0 && rect.height === 0) return;
+
+ const computedStyle = window.getComputedStyle(word.native);
+ const clone = word.native.cloneNode(true) as HTMLElement;
+ const randomRotation = (Math.random() - 0.5) * 45;
+ const randomX = (Math.random() - 0.5) * 100;
+
+ clone.classList.add("tumble-clone");
+ clone.style.position = "fixed";
+ clone.style.top = `${rect.top}px`;
+ clone.style.left = `${rect.left}px`;
+ clone.style.width = `${rect.width}px`;
+ clone.style.height = `${rect.height}px`;
+ clone.style.fontSize = computedStyle.fontSize;
+ clone.style.fontFamily = computedStyle.fontFamily;
+ clone.style.color = computedStyle.color;
+ clone.style.margin = "0";
+ clone.style.pointerEvents = "none";
+ clone.style.zIndex = "1000";
+ clone.style.setProperty("--fall-rotation", `${randomRotation}deg`);
+ clone.style.setProperty("--fall-x", `${randomX}px`);
+
+ document.body.appendChild(clone);
+ word.setStyle({ opacity: "0" });
+
+ const cleanup = (): void => {
+ clone.remove();
+ };
+
+ clone.addEventListener("animationend", cleanup, { once: true });
+ window.setTimeout(cleanup, TUMBLE_DURATION_MS);
+}
diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts
index 77c257a54834..15ef1c92c88f 100644
--- a/packages/schemas/src/configs.ts
+++ b/packages/schemas/src/configs.ts
@@ -182,7 +182,13 @@ export const HighlightModeSchema = z.enum([
]);
export type HighlightMode = z.infer;
-export const TypedEffectSchema = z.enum(["keep", "hide", "fade", "dots"]);
+export const TypedEffectSchema = z.enum([
+ "keep",
+ "hide",
+ "fade",
+ "dots",
+ "tumble",
+]);
export type TypedEffect = z.infer;
export const TapeModeSchema = z.enum(["off", "letter", "word"]);