<script lang="ts">
import { motion, animate, transform } from '@humanspeak/svelte-motion'
// Characters-remaining counter with a spring-bounce when getting
// close to the limit. `transform` builds a value-mapping function
// (a → b → c), and `animate(...)` runs an imperative spring keyed
// by the remaining-character count.
// Ported from: https://examples.motion.dev/react/characters-remaining
let value = $state('')
const maxLength = 12
const charactersRemaining = $derived(maxLength - value.length)
let counterEl: HTMLElement | null = $state(null)
// Map remaining count to a colour gradient: at 6+ characters left
// the counter sits muted grey; from 5 down to 2 it warms toward
// pink; at 0 it's full pink.
const mapRemainingToColor = transform([2, 6], ['#ff008c', '#ccc'])
$effect(() => {
if (charactersRemaining > 6 || !counterEl) return
// The spring's velocity also scales with remaining count —
// 5 left = subtle nudge; 0 left = aggressive bounce.
const mapRemainingToSpringVelocity = transform([0, 5], [50, 0])
animate(
counterEl,
{ scale: 1 },
{
type: 'spring',
velocity: mapRemainingToSpringVelocity(charactersRemaining),
stiffness: 700,
damping: 80
}
)
})
</script>
<div class="outer">
<div class="input-container">
<input bind:value />
<div class="counter">
<motion.span
bind:ref={counterEl}
style="color: {mapRemainingToColor(
charactersRemaining
)}; will-change: transform; display: block;"
>
{charactersRemaining}
</motion.span>
</div>
</div>
</div>
<style>
.outer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: transparent;
border-radius: 12px;
padding: 40px;
}
:global(.dark) .outer {
background: #0b1011;
}
.input-container {
position: relative;
font-size: 32px;
line-height: 1;
}
.input-container input {
position: relative;
font-size: 32px;
line-height: 1;
background-color: #ffffff;
color: #1a1a1a;
border: 2px solid #d1d5db;
border-radius: 10px;
padding: 20px;
padding-right: 70px;
width: 300px;
outline: none;
}
:global(.dark) .input-container input {
background-color: #0b1011;
color: #f5f5f5;
border-color: #1d2628;
}
.input-container input:focus {
border-color: #3b82f6;
}
.counter {
color: #6b7280;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, #ffffff 20%);
position: absolute;
top: 50%;
right: 2px;
transform: translateY(-50%);
padding: 10px;
padding-right: 20px;
padding-left: 50px;
}
:global(.dark) .counter {
color: #ccc;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, #0b1011 20%);
}
</style>
<script lang="ts">
import { motion, animate, transform } from '@humanspeak/svelte-motion'
// Characters-remaining counter with a spring-bounce when getting
// close to the limit. `transform` builds a value-mapping function
// (a → b → c), and `animate(...)` runs an imperative spring keyed
// by the remaining-character count.
// Ported from: https://examples.motion.dev/react/characters-remaining
let value = $state('')
const maxLength = 12
const charactersRemaining = $derived(maxLength - value.length)
let counterEl: HTMLElement | null = $state(null)
// Map remaining count to a colour gradient: at 6+ characters left
// the counter sits muted grey; from 5 down to 2 it warms toward
// pink; at 0 it's full pink.
const mapRemainingToColor = transform([2, 6], ['#ff008c', '#ccc'])
$effect(() => {
if (charactersRemaining > 6 || !counterEl) return
// The spring's velocity also scales with remaining count —
// 5 left = subtle nudge; 0 left = aggressive bounce.
const mapRemainingToSpringVelocity = transform([0, 5], [50, 0])
animate(
counterEl,
{ scale: 1 },
{
type: 'spring',
velocity: mapRemainingToSpringVelocity(charactersRemaining),
stiffness: 700,
damping: 80
}
)
})
</script>
<div class="outer">
<div class="input-container">
<input bind:value />
<div class="counter">
<motion.span
bind:ref={counterEl}
style="color: {mapRemainingToColor(
charactersRemaining
)}; will-change: transform; display: block;"
>
{charactersRemaining}
</motion.span>
</div>
</div>
</div>
<style>
.outer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: transparent;
border-radius: 12px;
padding: 40px;
}
:global(.dark) .outer {
background: #0b1011;
}
.input-container {
position: relative;
font-size: 32px;
line-height: 1;
}
.input-container input {
position: relative;
font-size: 32px;
line-height: 1;
background-color: #ffffff;
color: #1a1a1a;
border: 2px solid #d1d5db;
border-radius: 10px;
padding: 20px;
padding-right: 70px;
width: 300px;
outline: none;
}
:global(.dark) .input-container input {
background-color: #0b1011;
color: #f5f5f5;
border-color: #1d2628;
}
.input-container input:focus {
border-color: #3b82f6;
}
.counter {
color: #6b7280;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, #ffffff 20%);
position: absolute;
top: 50%;
right: 2px;
transform: translateY(-50%);
padding: 10px;
padding-right: 20px;
padding-left: 50px;
}
:global(.dark) .counter {
color: #ccc;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, #0b1011 20%);
}
</style>