async
lifecycle
browser
utilities
Hey siberiacancode 👋04:36 PM
SC
Hey! What’s up?04:36 PM
Just shipped useAutoScroll today04:36 PM
SC
Nice! Does it pause on manual scroll?04:36 PM
Yep, it just works ✨04:36 PM
SC
Let me try it out04:36 PM
import type { SubmitEvent } from 'react';
import { useAutoScroll, useEventListener, useField } from '@siberiacancode/reactuse';
import { ArrowDownIcon, SendIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/utils/lib';
interface Message {
author: 'reactuse' | 'siberiacancode';
avatar?: string;
id: number;
text: string;
time: string;
}
const POKEMON_IDS = [1, 4, 7];
const random = <T,>(items: T[]): T => items[Math.floor(Math.random() * items.length)];
const getPokemonAvatar = (id: number) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`;
const getRandomPokemonAvatar = () => getPokemonAvatar(random(POKEMON_IDS));
const REACTUSE_REPLIES = [
'Hey! How is the new release going?',
'I just shipped a new hook 🚀',
'Check out useAutoScroll — it just works.',
'You can scroll up and pause auto-scroll, btw.',
'No dependencies, no config needed.',
'Coffee break? ☕',
'Did you see the new docs?',
'TypeScript types are fully covered.',
'Working on a fresh demo right now.',
'Star us on GitHub if you like it ⭐',
'Got any feedback on the API?',
'How do you like the new docs theme?',
'Have you tried useDisclosure yet?',
'I love how composable React hooks are',
'BRB, grabbing some snacks 🍪',
'Anyone else excited for the next release?'
];
const formatTime = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const INITIAL_MESSAGES: Message[] = [
{
id: 1,
author: 'reactuse',
text: 'Hey siberiacancode 👋',
time: formatTime(),
avatar: getRandomPokemonAvatar()
},
{ id: 2, author: 'siberiacancode', text: 'Hey! What’s up?', time: formatTime() },
{
id: 3,
author: 'reactuse',
text: 'Just shipped useAutoScroll today',
time: formatTime(),
avatar: getRandomPokemonAvatar()
},
{
id: 4,
author: 'siberiacancode',
text: 'Nice! Does it pause on manual scroll?',
time: formatTime()
},
{
id: 5,
author: 'reactuse',
text: 'Yep, it just works ✨',
time: formatTime(),
avatar: getRandomPokemonAvatar()
},
{ id: 6, author: 'siberiacancode', text: 'Let me try it out', time: formatTime() }
];
const SCROLL_THRESHOLD = 20;
const MAX_MESSAGES = 20;
const Demo = () => {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const messageField = useField('');
const [showNewMessage, setShowNewMessage] = useState(false);
const autoScrollRef = useAutoScroll<HTMLDivElement>();
const idRef = useRef(INITIAL_MESSAGES.length + 1);
useEventListener(autoScrollRef, 'scroll', () => {
const container = autoScrollRef.current;
if (!container) return;
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < SCROLL_THRESHOLD;
if (isAtBottom) setShowNewMessage(false);
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNext = () => {
const delay = 2500 + Math.random() * 3500;
timeoutId = setTimeout(() => {
const container = autoScrollRef.current;
if (!container) return;
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < SCROLL_THRESHOLD;
setMessages((currentMessages) =>
[
...currentMessages,
{
id: idRef.current++,
author: 'reactuse' as const,
text: random(REACTUSE_REPLIES),
time: formatTime(),
avatar: getRandomPokemonAvatar()
}
].slice(-MAX_MESSAGES)
);
if (!isAtBottom) setShowNewMessage(true);
scheduleNext();
}, delay);
};
scheduleNext();
return () => clearTimeout(timeoutId);
}, []);
const onScrollToBottom = () => {
const container = autoScrollRef.current;
if (!container) return;
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
setShowNewMessage(false);
};
const message = messageField.watch();
const onSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = message.trim();
if (!trimmed) return;
setMessages(
[
...messages,
{
id: idRef.current++,
author: 'siberiacancode' as const,
text: trimmed,
time: formatTime()
}
].slice(-MAX_MESSAGES)
);
messageField.setValue('');
setShowNewMessage(false);
const container = autoScrollRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight + 50,
behavior: 'smooth'
});
};
return (
<section className='flex w-md min-w-xs flex-col items-center'>
<div className='flex w-full flex-col gap-3 rounded-2xl border px-4 pb-4'>
<div className='relative'>
<div
ref={autoScrollRef}
className='no-scrollbar flex h-80 flex-col gap-3 overflow-y-auto scroll-smooth'
>
{messages.map((message) => {
const isMe = message.author === 'siberiacancode';
return (
<div
key={message.id}
className={cn('flex items-end gap-2', isMe && 'flex-row-reverse')}
>
{isMe && (
<div className='flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-neutral-700 to-neutral-900 text-[10px] font-semibold text-white'>
SC
</div>
)}
{!isMe && (
<div className='size-7 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800'>
<img
alt='reactuse'
className='size-full translate-x-1 translate-y-1.5 scale-130 object-cover object-top'
src={message.avatar}
/>
</div>
)}
<div
className={cn(
'relative flex max-w-[75%] items-end gap-2 rounded-xl px-3 py-2 pr-12 text-sm',
isMe
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-muted rounded-bl-sm'
)}
>
<span>{message.text}</span>
<span
className={cn(
'absolute right-3 bottom-1 text-[9px] opacity-60',
isMe ? 'text-primary-foreground' : 'text-muted-foreground'
)}
>
{message.time}
</span>
</div>
</div>
);
})}
</div>
{showNewMessage && (
<button
className='absolute bottom-2 left-1/2 flex h-7 -translate-x-1/2 items-center gap-1.5 rounded-full px-3 text-xs shadow-md'
data-variant='outline'
type='button'
onClick={onScrollToBottom}
>
<ArrowDownIcon className='size-3' />
new message
</button>
)}
</div>
<form className='relative flex items-center gap-2' onSubmit={onSubmit}>
<input
className='h-11! rounded-full!'
placeholder='Type a message...'
{...messageField.register()}
/>
<button
className='absolute top-1/2 right-1 h-8! -translate-y-1/2 rounded-full! p-2!'
disabled={!message}
type='submit'
>
<SendIcon className='size-5' />
</button>
</form>
</div>
</section>
);
};
export default Demo;
Installation
pnpm add @siberiacancode/reactuseUsage
useAutoScroll(ref);
// or
const ref = useAutoScroll();Type Declarations
import type { HookTarget } from '@/utils/helpers';
import type { StateRef } from '../useRefState/useRefState';
export interface UseAutoScrollOptions {
/** Whether auto-scrolling is enabled */
enabled?: boolean;
/** Whether to force auto-scrolling regardless of user interactions */
force?: boolean;
}
export interface UseAutoScroll {
(target: HookTarget, options?: UseAutoScrollOptions): void;
<Target extends HTMLElement>(options?: UseAutoScrollOptions): StateRef<Target>;
}API
Parameters
| Name | Type | Default | Note |
|---|---|---|---|
| target | HookTarget | - | The target element to auto-scroll |
| options.enabled | boolean | - | Whether auto-scrolling is enabled |
Returns
voidParameters
| Name | Type | Default | Note |
|---|---|---|---|
| options.enabled | boolean | - | Whether auto-scrolling is enabled |
Returns
StateRef<Target>Contributors
ddebabin
Last updated on