Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions submissions/examples/otp-input/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# OTP Input Component

A row of 6 individual digit input boxes for entering one-time passwords or verification codes. Features auto-focus to next box on input, backspace to go back, paste support, and animated states (pulse on empty, pop on fill, shake on error, staggered success reveal). Includes inline JavaScript for input handling.

## Classes

| Class | Description |
|---|---|
| `ease-otp-input` | Flex container for the digit boxes |
| `ease-otp-box` | Individual digit input |
| `ease-otp-box--active` | Currently focused box |
| `ease-otp-box--filled` | Box with a digit entered |
| `ease-otp-input--error` | Error state (red shake on all boxes) |
| `ease-otp-input--success` | Success state (staggered green pop) |

## Usage

```html
<div class="ease-otp-input" data-ease-otp-length="6">
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<!-- repeat for 6 boxes -->
</div>
```

See `demo.html` for the full JS setup (auto-focus, backspace, paste, Enter to validate).

## Why it fits EaseMotion CSS

Four animated states with `ease-` prefixed keyframes: `ease-kf-otp-pulse` (empty waiting), `ease-kf-otp-pop` (digit entry), `ease-kf-otp-shake` (error), and `ease-kf-otp-success-pop` (staggered success). Smooth border and transform transitions. Respects `prefers-reduced-motion`.
95 changes: 95 additions & 0 deletions submissions/examples/otp-input/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OTP Input β€” EaseMotion CSS</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>

<div class="ease-otp-input" data-ease-otp-length="6">
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
<input class="ease-otp-box" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" />
</div>

<script>
const container = document.querySelector('.ease-otp-input');
const boxes = container.querySelectorAll('.ease-otp-box');

function getCode() {
return Array.from(boxes).map(b => b.value).join('');
}

boxes.forEach((box, i) => {
box.addEventListener('input', (e) => {
const digit = e.target.value.replace(/\D/g, '').slice(-1);
box.value = digit;
box.classList.toggle('ease-otp-box--filled', !!digit);
if (digit && i < boxes.length - 1) {
boxes[i + 1].focus();
}
container.classList.remove('ease-otp-input--error', 'ease-otp-input--success');
});

box.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !box.value && i > 0) {
boxes[i - 1].value = '';
boxes[i - 1].classList.remove('ease-otp-box--filled');
boxes[i - 1].focus();
}
if (e.key === 'ArrowLeft' && i > 0) boxes[i - 1].focus();
if (e.key === 'ArrowRight' && i < boxes.length - 1) boxes[i + 1].focus();
});

box.addEventListener('focus', () => {
box.classList.add('ease-otp-box--active');
});

box.addEventListener('blur', () => {
box.classList.remove('ease-otp-box--active');
});

box.addEventListener('paste', (e) => {
e.preventDefault();
const paste = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
paste.split('').slice(0, boxes.length - i).forEach((char, j) => {
const target = boxes[i + j];
if (target) {
target.value = char;
target.classList.add('ease-otp-box--filled');
}
});
const next = Math.min(i + paste.length, boxes.length - 1);
boxes[next].focus();
container.classList.remove('ease-otp-input--error', 'ease-otp-input--success');
});
});

// Demo: simulate error or success based on code
container.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const code = getCode();
if (code.length < 6) return;
if (code === '123456') {
container.classList.remove('ease-otp-input--error');
container.classList.add('ease-otp-input--success');
} else {
container.classList.remove('ease-otp-input--success');
container.classList.add('ease-otp-input--error');
setTimeout(() => {
container.classList.remove('ease-otp-input--error');
boxes.forEach(b => { b.value = ''; b.classList.remove('ease-otp-box--filled'); });
boxes[0].focus();
}, 1000);
}
}
});
</script>

</body>
</html>
133 changes: 133 additions & 0 deletions submissions/examples/otp-input/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* ============================================================
EaseMotion CSS β€” OTP Input Component
Issue #1200
============================================================ */

/* ── Container ────────────────────────────────────────────── */

.ease-otp-input {
display: flex;
gap: 12px;
justify-content: center;
align-items: center;
max-width: 480px;
margin: 2rem auto;
}

/* ── Box ──────────────────────────────────────────────────── */

.ease-otp-box {
width: 52px;
height: 56px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-radius: var(--ease-radius-md, 0.625rem);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
font-family: var(--ease-font-mono, ui-monospace, "SF Mono", monospace);
text-align: center;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
caret-color: #7c6cff;
outline: none;
transition:
border-color var(--ease-speed-fast, 150ms) cubic-bezier(0.4, 0, 0.2, 1),
transform var(--ease-speed-fast, 150ms) cubic-bezier(0.4, 0, 0.2, 1),
box-shadow var(--ease-speed-fast, 150ms) cubic-bezier(0.4, 0, 0.2, 1),
background var(--ease-speed-fast, 150ms) cubic-bezier(0.4, 0, 0.2, 1);
animation: ease-kf-otp-pulse 2.5s ease-in-out infinite;
}

.ease-otp-box:focus {
border-color: #7c6cff;
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.15);
animation: none;
}

.ease-otp-box--active {
border-color: #7c6cff;
animation: none;
}

/* ── Filled state ─────────────────────────────────────────── */

.ease-otp-box--filled {
animation: ease-kf-otp-pop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
border-color: #7c6cff;
}

/* ── Error state ──────────────────────────────────────────── */

.ease-otp-input--error .ease-otp-box {
border-color: #ef4444;
animation: ease-kf-otp-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);
color: #ef4444;
}

.ease-otp-input--error .ease-otp-box:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}

/* ── Success state ────────────────────────────────────────── */

.ease-otp-input--success .ease-otp-box {
border-color: #22c55e;
animation: ease-kf-otp-success-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}

.ease-otp-input--success .ease-otp-box:nth-child(2) { animation-delay: 0.04s; }
.ease-otp-input--success .ease-otp-box:nth-child(3) { animation-delay: 0.08s; }
.ease-otp-input--success .ease-otp-box:nth-child(4) { animation-delay: 0.12s; }
.ease-otp-input--success .ease-otp-box:nth-child(5) { animation-delay: 0.16s; }
.ease-otp-input--success .ease-otp-box:nth-child(6) { animation-delay: 0.20s; }

/* ── Keyframes ────────────────────────────────────────────── */

@keyframes ease-kf-otp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

@keyframes ease-kf-otp-pop {
0% { transform: scale(0.85); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}

@keyframes ease-kf-otp-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}

@keyframes ease-kf-otp-success-pop {
0% { transform: scale(1); background: rgba(34, 197, 94, 0); }
50% { transform: scale(1.15); }
100% { transform: scale(1); background: rgba(34, 197, 94, 0.1); }
}

/* ── Reduced motion ──────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
.ease-otp-box {
animation: none;
}
.ease-otp-box:focus {
animation: none;
}
.ease-otp-box--filled {
animation: none;
}
.ease-otp-input--error .ease-otp-box {
animation: none;
}
.ease-otp-input--success .ease-otp-box {
animation: none;
}
}
Loading