docs(vue): add interactive demo for every composable

A beautiful, SSR-safe demo.vue next to each composable, auto-discovered by the docs extractor and rendered client-only on each composable's page.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 59e995d0b5
commit e83f10fe32
214 changed files with 19584 additions and 74 deletions
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue';
import { useAnimate } from './index';
const target = useTemplateRef<HTMLElement>('target');
const {
isSupported,
play,
pause,
reverse,
finish,
cancel,
playState,
currentTime,
playbackRate,
} = useAnimate(
target,
[
{ transform: 'translateX(-3.5rem) rotate(0deg)', borderRadius: '0.75rem' },
{ transform: 'translateX(0) rotate(180deg)', borderRadius: '50%' },
{ transform: 'translateX(3.5rem) rotate(360deg)', borderRadius: '0.75rem' },
],
{
duration: 2000,
iterations: Infinity,
direction: 'alternate',
easing: 'ease-in-out',
immediate: false,
},
);
const elapsed = computed(() => {
const t = currentTime.value;
return typeof t === 'number' ? `${(t / 1000).toFixed(2)}s` : '—';
});
const stateColor = computed(() => {
switch (playState.value) {
case 'running': return 'bg-emerald-500';
case 'paused': return 'bg-amber-500';
case 'finished': return 'bg-sky-500';
default: return 'bg-(--border-strong)';
}
});
const rates = [0.5, 1, 2] as const;
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-600 dark:text-amber-400"
>
The Web Animations API is not supported in this browser.
</div>
<template v-else>
<div class="flex h-28 items-center justify-center overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)">
<div
ref="target"
class="size-12 bg-(--accent) shadow-lg"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
State
</div>
<div class="mt-1 flex items-center gap-2">
<span class="inline-block size-2 rounded-full transition" :class="stateColor" />
<span class="font-mono text-sm text-(--fg)">{{ playState }}</span>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Current time
</div>
<div class="mt-1 font-mono text-sm tabular-nums text-(--fg)">
{{ elapsed }}
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="play"
>
Play
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="pause"
>
Pause
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="reverse"
>
Reverse
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="finish"
>
Finish
</button>
<button
class="col-span-2 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="cancel"
>
Cancel
</button>
</div>
<div class="flex flex-col gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Playback rate
</div>
<div class="flex gap-2">
<button
v-for="rate in rates"
:key="rate"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium tabular-nums transition active:scale-[0.98] cursor-pointer"
:class="playbackRate === rate
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="playbackRate = rate"
>
{{ rate }}×
</button>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useCountdown } from './index';
const initial = ref(60);
const justFinished = ref(false);
const { remaining, isActive, start, stop, pause, resume } = useCountdown(initial, {
onComplete: () => {
justFinished.value = true;
},
onTick: () => {
justFinished.value = false;
},
});
const minutes = computed(() => String(Math.floor(remaining.value / 60)).padStart(2, '0'));
const seconds = computed(() => String(remaining.value % 60).padStart(2, '0'));
const progress = computed(() =>
initial.value > 0 ? Math.max(0, Math.min(1, remaining.value / initial.value)) : 0,
);
const presets = [30, 60, 300] as const;
function setPreset(value: number) {
initial.value = value;
start(value);
}
function toggle() {
if (isActive.value)
pause();
else
resume();
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Time remaining
</div>
<div
class="mt-2 font-mono text-5xl font-bold tabular-nums transition-colors"
:class="justFinished
? 'text-emerald-600 dark:text-emerald-400'
: remaining <= 10 && remaining > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-(--fg)'"
>
{{ minutes }}:{{ seconds }}
</div>
<div class="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
<div
class="h-full rounded-full bg-(--accent) transition-[width] duration-300 ease-linear"
:style="{ width: `${progress * 100}%` }"
/>
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : justFinished ? 'bg-sky-500' : 'bg-(--border-strong)'"
/>
{{ justFinished ? 'Completed' : isActive ? 'Counting down' : 'Paused' }}
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="preset in presets"
:key="preset"
class="rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium tabular-nums text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="setPreset(preset)"
>
{{ preset < 60 ? `${preset}s` : `${preset / 60}m` }}
</button>
</div>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="remaining === 0 && isActive"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="start()"
>
Restart
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="stop"
>
Stop
</button>
</div>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useDateFormat } from './index';
// A fixed, real-looking moment so the demo is deterministic across renders.
const date = ref('2026-06-08T14:37:09');
const format = ref('dddd, MMMM Do YYYY — hh:mm:ss a');
const locale = ref('en-US');
const formatted = useDateFormat(date, format, { locales: locale });
const formats = [
'YYYY-MM-DD HH:mm:ss',
'dddd, MMMM Do YYYY — hh:mm:ss a',
'ddd D MMM \'YY',
'h:mm A',
] as const;
const locales = [
{ value: 'en-US', label: 'English' },
{ value: 'fr-FR', label: 'Français' },
{ value: 'de-DE', label: 'Deutsch' },
{ value: 'ja-JP', label: '日本語' },
] as const;
const isValid = computed(() => formatted.value !== 'Invalid Date');
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Formatted output
</div>
<div
class="mt-2 font-mono text-lg font-semibold tabular-nums"
:class="isValid ? 'text-(--fg)' : 'text-red-600 dark:text-red-400'"
>
{{ formatted }}
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Date input
</label>
<input
v-model="date"
type="datetime-local"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Format token string
</label>
<input
v-model="format"
type="text"
spellcheck="false"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
<div class="flex flex-wrap gap-1.5 pt-1">
<button
v-for="f in formats"
:key="f"
class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-mono text-xs text-(--fg-muted) transition hover:bg-(--bg-elevated) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
:class="{ 'border-(--accent) text-(--accent-text)': format === f }"
@click="format = f"
>
{{ f }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Locale
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="loc in locales"
:key="loc.value"
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="locale === loc.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="locale = loc.value"
>
{{ loc.label }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useInterval } from './index';
const interval = ref(1000);
const { counter, isActive, pause, resume, reset } = useInterval(interval, {
controls: true,
immediate: true,
});
// A simple visual pulse driven purely off the reactive tick counter.
const beats = computed(() => Array.from({ length: 8 }, (_, i) => i === counter.value % 8));
const speeds = [
{ value: 2000, label: 'Slow' },
{ value: 1000, label: 'Normal' },
{ value: 400, label: 'Fast' },
] as const;
function toggle() {
if (isActive.value)
pause();
else
resume();
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Ticks elapsed
</div>
<div class="mt-2 font-mono text-5xl font-bold tabular-nums text-(--fg)">
{{ counter }}
</div>
<div class="mt-4 flex items-center justify-center gap-1.5">
<span
v-for="(on, i) in beats"
:key="i"
class="size-2.5 rounded-full transition-colors duration-200"
:class="on ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
/>
</div>
<div class="mt-4 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
{{ isActive ? `Ticking every ${interval}ms` : 'Paused' }}
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Interval speed
</div>
<div class="flex gap-2">
<button
v-for="speed in speeds"
:key="speed.value"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="interval = speed.value"
>
{{ speed.label }}
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="counter === 0"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useIntervalFn } from './index';
const interval = ref(800);
const logs = ref<{ id: number; time: string }[]>([]);
let nextId = 0;
const { isActive, pause, resume, toggle } = useIntervalFn(
() => {
logs.value.unshift({
id: nextId++,
time: new Date().toLocaleTimeString(undefined, { hour12: false }),
});
if (logs.value.length > 6)
logs.value.length = 6;
},
interval,
{ immediate: false },
);
const speeds = [
{ value: 1500, label: 'Slow' },
{ value: 800, label: 'Normal' },
{ value: 300, label: 'Fast' },
] as const;
function clear() {
logs.value = [];
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div>
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Interval callback
</div>
<div class="mt-1 flex items-center gap-2 text-sm text-(--fg-muted)">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
{{ isActive ? `Firing every ${interval}ms` : 'Stopped' }}
</div>
</div>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Start' }}
</button>
</div>
<div class="flex flex-col gap-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Interval
</div>
<div class="flex gap-2">
<button
v-for="speed in speeds"
:key="speed.value"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="interval = speed.value"
>
{{ speed.label }}
</button>
</div>
<p class="text-xs text-(--fg-subtle)">
Changing the interval while running restarts the timer with the new duration.
</p>
</div>
<div class="flex items-center justify-between">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Tick log
</div>
<button
class="text-xs text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
:disabled="logs.length === 0"
@click="clear"
>
Clear
</button>
</div>
<div class="min-h-32 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p v-if="logs.length === 0" class="py-6 text-center text-sm text-(--fg-subtle)">
No ticks yet press Start.
</p>
<ul v-else class="flex flex-col gap-1.5">
<li
v-for="log in logs"
:key="log.id"
class="flex items-center gap-2 font-mono text-sm tabular-nums text-(--fg)"
>
<span class="inline-block size-1.5 rounded-full bg-(--accent)" />
{{ log.time }}
</li>
</ul>
</div>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="isActive"
@click="resume"
>
Resume
</button>
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isActive"
@click="pause"
>
Pause
</button>
</div>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useNow } from './index';
const { now, isActive, pause, resume, toggle } = useNow({ controls: true, interval: 'requestAnimationFrame' });
const time = computed(() =>
now.value.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }),
);
const millis = computed(() => now.value.getMilliseconds().toString().padStart(3, '0'));
const date = computed(() =>
now.value.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }),
);
// A sweeping second hand driven entirely by the reactive `now`.
const secondAngle = computed(() => {
const seconds = now.value.getSeconds() + now.value.getMilliseconds() / 1000;
return seconds / 60 * 360;
});
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive now</div>
<div class="flex items-baseline gap-1">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ time }}</span>
<span class="font-mono text-lg font-semibold tabular-nums text-(--fg-subtle)">.{{ millis }}</span>
</div>
<div class="text-sm text-(--fg-muted)">{{ date }}</div>
<div class="relative mt-1 size-24 rounded-full border-2 border-(--border-strong) bg-(--bg-inset)">
<div class="absolute inset-0 flex items-center justify-center">
<div class="size-1.5 rounded-full bg-(--accent)" />
</div>
<div
class="absolute bottom-1/2 left-1/2 h-9 w-0.5 origin-bottom rounded-full bg-(--accent)"
:style="{ transform: `translateX(-50%) rotate(${secondAngle}deg)` }"
/>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<span
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
<span
class="size-1.5 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
/>
{{ isActive ? 'Ticking (RAF)' : 'Paused' }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
type="button"
:disabled="isActive"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="resume"
>
Resume
</button>
<button
type="button"
:disabled="!isActive"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="pause"
>
Pause
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useRafFn } from './index';
const fpsLimit = ref(0);
const position = ref(0);
const direction = ref(1);
const delta = shallowRef(0);
const fps = shallowRef(0);
const frames = ref(0);
const { isActive, pause, resume, toggle } = useRafFn(
({ delta: d }) => {
delta.value = d;
fps.value = d > 0 ? Math.round(1000 / d) : 0;
frames.value++;
// Bounce a marker across the track using real frame delta (px per second).
position.value += direction.value * (d / 1000) * 120;
if (position.value >= 100) {
position.value = 100;
direction.value = -1;
}
else if (position.value <= 0) {
position.value = 0;
direction.value = 1;
}
},
{ fpsLimit: fpsLimit.value || undefined },
);
const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLimit.value} fps`));
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">requestAnimationFrame</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ isActive ? 'Running' : 'Paused' }}
</span>
</div>
<!-- The animated track: marker position is updated every frame -->
<div class="relative mx-2.5 h-8 rounded-lg border border-(--border) bg-(--bg-inset)">
<div
class="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) shadow"
:style="{ left: `${position}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ fps }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">fps</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ delta.toFixed(1) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">delta ms</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ frames }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">frames</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="fps-limit">FPS limit</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ limitLabel }}</span>
</div>
<input
id="fps-limit"
v-model.number="fpsLimit"
type="range"
min="0"
max="60"
step="5"
class="w-full accent-(--accent) cursor-pointer"
>
<p class="text-xs text-(--fg-subtle)">Changing the limit takes effect on the next mount; toggle below to see it live.</p>
</div>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause loop' : 'Resume loop' }}
</button>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useTimeAgo } from './index';
interface Preset {
label: string;
offset: number;
}
// Offsets in ms relative to now; negative = past, positive = future.
const presets: Preset[] = [
{ label: '15 s ago', offset: -15_000 },
{ label: '3 min ago', offset: -3 * 60_000 },
{ label: '2 h ago', offset: -2 * 3_600_000 },
{ label: 'Yesterday', offset: -86_400_000 },
{ label: 'Last week', offset: -7 * 86_400_000 },
{ label: '5 months ago', offset: -5 * 2_592_000_000 },
{ label: 'In 45 min', offset: 45 * 60_000 },
{ label: 'Next year', offset: 31_536_000_000 },
];
const offset = ref(presets[1]!.offset);
// Reactive getter recomputed each tick so the string stays live.
const target = computed(() => Date.now() + offset.value);
const { timeAgo, isActive, toggle } = useTimeAgo(target, {
controls: true,
updateInterval: 1000,
showSecond: true,
});
const absolute = computed(() =>
new Date(target.value).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
);
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Relative time</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg) text-center">{{ timeAgo }}</span>
<span class="text-xs text-(--fg-muted)">{{ absolute }}</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Pick an instant</span>
<div class="grid grid-cols-2 gap-2">
<button
v-for="preset in presets"
:key="preset.label"
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="offset === preset.offset
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="offset = preset.offset"
>
{{ preset.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<span
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ isActive ? 'Updating every 1s' : 'Updates paused' }}
</span>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTimeout } from './index';
const delay = ref(3000);
const firedAt = ref<string | null>(null);
const { ready, start, stop } = useTimeout(delay, {
controls: true,
immediate: false,
callback: () => {
firedAt.value = new Date().toLocaleTimeString('en-US', { hour12: false });
},
});
function restart() {
firedAt.value = null;
start();
}
function cancel() {
stop();
}
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Status</span>
<div
class="flex size-20 items-center justify-center rounded-full border-2 transition"
:class="ready
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'"
>
<span class="text-sm font-semibold">{{ ready ? 'Ready' : 'Pending' }}</span>
</div>
<p class="text-center text-sm text-(--fg-muted)">
<template v-if="ready && firedAt">Fired at <span class="font-mono tabular-nums text-(--fg)">{{ firedAt }}</span></template>
<template v-else-if="ready">Idle start the timer below</template>
<template v-else>Counting down stays pending until the delay elapses</template>
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="delay">Delay</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ (delay / 1000).toFixed(1) }}s</span>
</div>
<input
id="delay"
v-model.number="delay"
type="range"
min="500"
max="5000"
step="500"
class="w-full accent-(--accent) cursor-pointer"
>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="restart"
>
{{ ready ? 'Start' : 'Restart' }}
</button>
<button
type="button"
:disabled="ready"
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="cancel"
>
Cancel
</button>
</div>
</div>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTimeoutFn } from './index';
interface Mail {
id: number;
from: string;
subject: string;
}
const inbox = ref<Mail[]>([
{ id: 1, from: 'Ada Lovelace', subject: 'Notes on the Analytical Engine' },
{ id: 2, from: 'Grace Hopper', subject: 'Found a bug in the relay' },
{ id: 3, from: 'Alan Turing', subject: 'Re: Halting problem' },
]);
const pendingDelete = ref<Mail | null>(null);
// After the grace period elapses the mail is permanently removed.
const { isPending, start, stop } = useTimeoutFn(
() => {
if (pendingDelete.value)
inbox.value = inbox.value.filter(m => m.id !== pendingDelete.value!.id);
pendingDelete.value = null;
},
5000,
{ immediate: false },
);
function archive(mail: Mail) {
stop();
pendingDelete.value = mail;
start();
}
function undo() {
stop();
pendingDelete.value = null;
}
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Inbox · undo with grace period</span>
<ul v-if="inbox.length" class="flex flex-col gap-2">
<li
v-for="mail in inbox"
:key="mail.id"
class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-3 transition"
:class="{ 'opacity-40': pendingDelete?.id === mail.id }"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-(--fg)">{{ mail.subject }}</div>
<div class="truncate text-xs text-(--fg-muted)">{{ mail.from }}</div>
</div>
<button
type="button"
:disabled="isPending"
class="shrink-0 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="archive(mail)"
>
Archive
</button>
</li>
</ul>
<div v-else class="rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center text-sm text-(--fg-subtle)">
Inbox zero everything archived.
</div>
<Transition
enter-active-class="transition duration-200" enter-from-class="opacity-0 translate-y-1"
leave-active-class="transition duration-150" leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isPending && pendingDelete"
class="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2"
>
<span class="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
<span class="size-1.5 animate-pulse rounded-full bg-amber-500" />
Archiving {{ pendingDelete.subject }}
</span>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-semibold text-amber-700 underline-offset-2 transition hover:underline dark:text-amber-400 cursor-pointer"
@click="undo"
>
Undo
</button>
</div>
</Transition>
</div>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useTimestamp } from './index';
const interval = ref(1000);
const offset = ref(0);
const { timestamp, isActive, pause, resume } = useTimestamp({
controls: true,
interval: 1000,
offset,
});
const clockTime = computed(() =>
new Date(timestamp.value).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
);
const clockDate = computed(() =>
new Date(timestamp.value).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
);
function toggle() {
if (isActive.value)
pause();
else
resume();
}
function shift(ms: number) {
offset.value += ms;
}
function resetOffset() {
offset.value = 0;
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Reactive timestamp
</div>
<div class="mt-2 font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ clockTime }}
</div>
<div class="mt-1 text-sm text-(--fg-muted)">
{{ clockDate }}
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
{{ isActive ? 'Updating every second' : 'Paused' }}
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
{{ Math.round(timestamp) }} ms
</div>
<div class="flex items-center justify-between gap-2">
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<div class="flex items-center gap-2">
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="shift(-3600_000)"
>
-1h
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="shift(3600_000)"
>
+1h
</button>
</div>
</div>
<div class="flex items-center justify-between text-sm text-(--fg-muted)">
<span>
Offset:
<span class="font-mono text-(--fg) tabular-nums">{{ (offset / 3600_000).toFixed(0) }}h</span>
</span>
<button
class="text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
:disabled="offset === 0"
@click="resetOffset"
>
Reset offset
</button>
</div>
</div>
</template>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TransitionPresets, useTransition } from './index';
type PresetName = keyof typeof TransitionPresets;
const presetNames = Object.keys(TransitionPresets) as PresetName[];
const preset = ref<PresetName>('easeOutCubic');
const duration = ref(800);
// Animated progress value (0100).
const target = ref(72);
const value = useTransition(target, {
duration,
transition: computed(() => TransitionPresets[preset.value]),
});
// Animated color tuple (RGB).
const swatches: Array<[string, [number, number, number]]> = [
['Indigo', [99, 102, 241]],
['Emerald', [16, 185, 129]],
['Amber', [245, 158, 11]],
['Rose', [244, 63, 94]],
];
const colorTarget = ref<[number, number, number]>([99, 102, 241]);
const color = useTransition(colorTarget, {
duration: 600,
transition: TransitionPresets.easeInOutQuad,
});
const colorCss = computed(() => {
const [r, g, b] = color.value;
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
});
function randomize() {
target.value = Math.round(Math.random() * 100);
}
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-5">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-baseline justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Eased value
</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ value.toFixed(1) }}
</span>
</div>
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
<div
class="h-full rounded-full bg-(--accent)"
:style="{ width: `${Math.max(0, Math.min(100, value))}%` }"
/>
</div>
<div class="mt-4 flex items-center gap-3">
<input
v-model.number="target"
type="range"
min="0"
max="100"
class="h-1.5 flex-1 cursor-pointer accent-(--accent)"
>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="randomize"
>
Random
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Easing preset
</label>
<select
v-model="preset"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
<option v-for="name in presetNames" :key="name" :value="name">
{{ name }}
</option>
</select>
<label class="mt-1 flex items-center justify-between text-sm text-(--fg-muted)">
<span>Duration</span>
<span class="font-mono text-(--fg) tabular-nums">{{ duration }}ms</span>
</label>
<input
v-model.number="duration"
type="range"
min="100"
max="2000"
step="100"
class="h-1.5 w-full cursor-pointer accent-(--accent)"
>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center gap-3">
<div
class="size-12 shrink-0 rounded-lg border border-(--border)"
:style="{ backgroundColor: colorCss }"
/>
<div class="min-w-0">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Animated tuple
</div>
<div class="font-mono text-sm text-(--fg) tabular-nums">
{{ colorCss }}
</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="[label, rgb] in swatches"
:key="label"
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted) transition hover:border-(--border-strong) cursor-pointer"
@click="colorTarget = [...rgb]"
>
<span class="size-2.5 rounded-full" :style="{ backgroundColor: `rgb(${rgb.join(',')})` }" />
{{ label }}
</button>
</div>
</div>
</div>
</template>