[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

Select the types of activity you want to include in your feed.

add timer, number input, small fixes, social icons preview

Florian (Mar 1, 2025, 11:28 PM +0100) 2ceb9d60 70ceae8b

+379 -39
+8 -1
src/lib/preview/ChatBubblePreview.svelte
··· 6 6 7 7 <Heading class="mb-4">Chat Bubble</Heading> 8 8 9 - <Box class="flex flex-col gap-2"> 9 + <Box class="flex flex-col gap-3"> 10 10 <ChatBubble side="right" variant="secondary"> 11 11 <p>Hello there</p> 12 12 </ChatBubble> 13 13 14 14 <ChatBubble> 15 15 <p>General Kenobi</p> 16 + </ChatBubble> 17 + 18 + <ChatBubble side="right" variant="secondary"> 19 + <p>This is a really long message that should wrap around the chat bubble and not overflow</p> 20 + </ChatBubble> 21 + <ChatBubble> 22 + <p>This is another really long message that should wrap around the chat bubble and not overflow</p> 16 23 </ChatBubble> 17 24 </Box>
+1 -1
src/lib/preview/ModalPreview.svelte
··· 21 21 22 22 <Button onclick={() => (openCustom = true)} variant="secondary">Custom Modal</Button> 23 23 24 - <Button onclick={() => (blueskyLoginModalState.visible = true)} variant="secondary" 24 + <Button onclick={() => (blueskyLoginModalState.open = true)} variant="secondary" 25 25 >Bluesky Login Modal</Button 26 26 > 27 27
+8
src/lib/preview/NumberInputPreview.svelte
··· 1 + <script> 2 + import Subheading from '$lib/components/heading/Subheading.svelte'; 3 + import NumberInput from '$lib/components/number-input/NumberInput.svelte'; 4 + </script> 5 + 6 + <Subheading class="mb-4">Number Input</Subheading> 7 + 8 + <NumberInput />
+7
src/lib/preview/SocialIconsPreview.svelte
··· 1 + <script> 2 + import { Subheading } from '$lib/components/heading'; 3 + import { SocialIcons } from '$lib/components/social-icons'; 4 + </script> 5 + 6 + <Subheading class="mb-4">Social Icons</Subheading> 7 + <SocialIcons github="#" facebook="#" twitter="#" youtube="#" discord="#" bluesky="#" />
+22
src/lib/preview/TimerPreview.svelte
··· 1 + <script> 2 + import Box from '$lib/components/box/Box.svelte'; 3 + import Button from '$lib/components/button/Button.svelte'; 4 + import Subheading from '$lib/components/heading/Subheading.svelte'; 5 + import NumberInput from '$lib/components/number-input/NumberInput.svelte'; 6 + import Timer from '$lib/components/timer/Timer.svelte'; 7 + import { TimerState } from '$lib/components/timer/TimerState.svelte'; 8 + 9 + let timer = new TimerState(1000 * 60 * 60); 10 + </script> 11 + 12 + <Subheading class="mb-4">Timer</Subheading> 13 + 14 + <Box class="min-h-0"> 15 + <Timer bind:timer /> 16 + </Box> 17 + 18 + <div class="flex gap-2 justify-center w-full"> 19 + <Button onclick={() => timer.start()}>Start</Button> 20 + <Button onclick={() => timer.stop()}>Stop</Button> 21 + <Button onclick={() => timer.reset()}>Reset</Button> 22 + </div>
+4 -1
src/routes/components/+layout.svelte
··· 44 44 { label: 'Textarea', href: 'textarea' }, 45 45 { label: 'Tooltip', href: 'tooltip' }, 46 46 { label: 'Video Player', href: 'video-player' }, 47 - { label: 'Modal', href: 'modal' } 47 + { label: 'Modal', href: 'modal' }, 48 + { label: 'Timer', href: 'timer' }, 49 + { label: 'Number Input', href: 'number-input' }, 50 + { label: 'Social Icons', href: 'social-icons' } 48 51 ]; 49 52 50 53 // sort components by label
+12
src/routes/components/+page.svelte
··· 30 30 import BoxPreview from '$lib/preview/BoxPreview.svelte'; 31 31 import ChatBubblePreview from '$lib/preview/ChatBubblePreview.svelte'; 32 32 import ModalPreview from '$lib/preview/ModalPreview.svelte'; 33 + import NumberInputPreview from '$lib/preview/NumberInputPreview.svelte'; 34 + import SocialIconsPreview from '$lib/preview/SocialIconsPreview.svelte'; 35 + import TimerPreview from '$lib/preview/TimerPreview.svelte'; 33 36 </script> 34 37 35 38 <Heading class="mb-8">All Components</Heading> ··· 86 89 <div class="flex flex-col items-start gap-4 py-8"> 87 90 <ModalPreview></ModalPreview> 88 91 </div> 92 + <div class="flex flex-col items-start gap-4 py-8"> 93 + <NumberInputPreview></NumberInputPreview> 94 + </div> 89 95 <div class="flex flex-col gap-4 py-8"> 90 96 <PhonePreview></PhonePreview> 91 97 </div> ··· 108 114 <SliderPreview></SliderPreview> 109 115 </div> 110 116 <div class="flex flex-col gap-4 py-8"> 117 + <SocialIconsPreview></SocialIconsPreview> 118 + </div> 119 + <div class="flex flex-col gap-4 py-8"> 111 120 <SonnerPreview></SonnerPreview> 112 121 </div> 113 122 <div class="flex flex-col gap-4 py-8"> ··· 118 127 </div> 119 128 <div class="flex flex-col gap-4 py-8"> 120 129 <TextareaPreview></TextareaPreview> 130 + </div> 131 + <div class="flex flex-col gap-4 py-8"> 132 + <TimerPreview></TimerPreview> 121 133 </div> 122 134 <div class="flex flex-col gap-4 py-8"> 123 135 <TooltipPreview></TooltipPreview>
+1 -1
src/lib/components/chat-bubble/ChatBubble.svelte
··· 78 78 <div class="grow"></div> 79 79 {/if} 80 80 81 - <span bind:this={ref} class={cn(badgeVariants({ variant, size }), className)} {...restProps}> 81 + <span bind:this={ref} class={cn(badgeVariants({ variant, size }), 'max-w-2/3 whitespace-normal', className)} {...restProps}> 82 82 {@render children?.()} 83 83 </span> 84 84
+23 -35
src/lib/components/modal/BlueskyLoginModal.svelte
··· 1 1 <script lang="ts" module> 2 2 export const blueskyLoginModalState = $state({ 3 - visible: false, 4 - show: () => (blueskyLoginModalState.visible = true), 5 - hide: () => (blueskyLoginModalState.visible = false) 3 + open: false, 4 + show: () => (blueskyLoginModalState.open = true), 5 + hide: () => (blueskyLoginModalState.open = false) 6 6 }); 7 7 </script> 8 8 9 9 <script lang="ts"> 10 10 import { Button } from '../button'; 11 - import { tick } from 'svelte'; 12 11 import Modal from './Modal.svelte'; 13 12 import { Subheading } from '../heading'; 14 13 import { Label } from '../label'; ··· 24 23 login: (handle: string) => Promise<void>; 25 24 } = $props(); 26 25 27 - async function onSubmit() { 26 + async function onSubmit(evt: Event) { 27 + evt.preventDefault(); 28 28 if (loading) return; 29 29 30 30 error = null; ··· 40 40 } 41 41 42 42 let input: HTMLInputElement | null = $state(null); 43 - 44 - $effect(() => { 45 - if (!blueskyLoginModalState.visible) { 46 - error = null; 47 - value = ''; 48 - loading = false; 49 - } else { 50 - tick().then(() => { 51 - input?.focus(); 52 - }); 53 - } 54 - }); 55 43 </script> 56 44 57 45 <Modal 58 - bind:open={blueskyLoginModalState.visible} 46 + bind:open={blueskyLoginModalState.open} 59 47 class="max-w-sm gap-2 p-4 sm:p-6" 60 48 onOpenAutoFocus={(e) => { 61 49 e.preventDefault(); 62 50 input?.focus(); 63 51 }} 64 52 > 65 - <Subheading class="mb-1 inline-flex items-center gap-2 text-xl font-bold"> 66 - <svg 67 - fill="currentColor" 68 - xmlns="http://www.w3.org/2000/svg" 69 - viewBox="-40 -40 680 620" 70 - version="1.1" 71 - class={['text-accent-600 dark:text-accent-400 size-6']} 72 - aria-hidden="true" 73 - > 74 - <path 75 - d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 76 - /> 77 - </svg> 78 - Login with Bluesky</Subheading 79 - > 80 - 81 53 <form onsubmit={onSubmit} class="flex flex-col gap-2"> 54 + <Subheading class="mb-1 inline-flex items-center gap-2 text-xl font-bold"> 55 + <svg 56 + fill="currentColor" 57 + xmlns="http://www.w3.org/2000/svg" 58 + viewBox="-40 -40 680 620" 59 + version="1.1" 60 + class={['text-accent-600 dark:text-accent-400 size-6']} 61 + aria-hidden="true" 62 + > 63 + <path 64 + d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 65 + /> 66 + </svg> 67 + Login with Bluesky</Subheading 68 + > 69 + 82 70 <div class="text-base-600 dark:text-base-400 text-xs leading-5"> 83 71 Don't have an account? 84 72 <br /> ··· 107 95 </div> 108 96 109 97 {#if error} 110 - <p class="mt-2 text-accent-500 text-sm">{error}</p> 98 + <p class="text-accent-500 mt-2 text-sm font-medium">{error}</p> 111 99 {/if} 112 100 113 101 <Button type="submit" class="mt-2 ml-auto w-full md:w-auto" disabled={loading}
+129
src/lib/components/number-input/NumberInput.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + import NumberFlow from '@number-flow/svelte'; 4 + import type { WithElementRef, WithoutChildrenOrChild } from 'bits-ui'; 5 + import type { HTMLAttributes } from 'svelte/elements'; 6 + 7 + let { 8 + min = 0, 9 + value = $bindable(50), 10 + max = 999, 11 + defaultValue = 0, 12 + step = 1, 13 + class: className, 14 + ref = $bindable(null), 15 + inputRef = $bindable(null), 16 + ...restProps 17 + }: WithElementRef<WithoutChildrenOrChild<HTMLAttributes<HTMLDivElement>>> & { 18 + min?: number; 19 + value?: number; 20 + max?: number; 21 + defaultValue?: number; 22 + class?: string; 23 + step?: number; 24 + inputRef?: HTMLInputElement | null; 25 + } = $props(); 26 + 27 + let input: HTMLInputElement; 28 + let animated = $state(true); 29 + let showCaret = $state(true); 30 + function handleInput() { 31 + if (!inputRef) return; 32 + animated = false; 33 + let next = value; 34 + if (inputRef.value === '') { 35 + next = defaultValue; 36 + } else { 37 + const num = parseInt(inputRef.value); 38 + if (!isNaN(num) && min <= num && num <= max) next = num; 39 + } 40 + inputRef.value = String(next); 41 + value = next; 42 + } 43 + function handlePointerDown(event: PointerEvent, diff: number) { 44 + animated = true; 45 + if (event.pointerType === 'mouse') { 46 + event?.preventDefault(); 47 + inputRef?.focus(); 48 + } 49 + const newVal = Math.min(Math.max(value + diff, min), max); 50 + value = newVal; 51 + } 52 + </script> 53 + 54 + <div 55 + class={cn( 56 + 'bg-accent-500/5 focus-within:ring-accent-400 dark:focus-within:ring-accent-600 text-accent-500 group ring-accent-500/20 dark:ring-accent-500/20 flex w-44 touch-manipulation items-stretch justify-between rounded-2xl text-3xl font-semibold ring transition-[box-shadow] focus-within:ring-2', 57 + className 58 + )} 59 + bind:this={ref} 60 + {...restProps} 61 + > 62 + <button 63 + aria-hidden="true" 64 + tabindex={-1} 65 + class="flex cursor-pointer items-center pr-[.325em] pl-[.5em] disabled:cursor-not-allowed disabled:opacity-50 hover:text-accent-600 dark:hover:text-accent-400 disabled:hover:text-accent-500 dark:disabled:hover:text-accent-500 transition-colors" 66 + disabled={min != null && value <= min} 67 + onpointerdown={(event) => handlePointerDown(event, -step)} 68 + > 69 + <svg 70 + xmlns="http://www.w3.org/2000/svg" 71 + fill="none" 72 + viewBox="0 0 24 24" 73 + stroke-width="3.5" 74 + stroke="currentColor" 75 + class="size-4" 76 + > 77 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /> 78 + </svg> 79 + </button> 80 + <div 81 + class="relative grid items-center justify-items-center text-center [grid-template-areas:'overlap'] *:[grid-area:overlap]" 82 + > 83 + <input 84 + class={cn( 85 + showCaret ? 'caret-accent-500' : 'caret-transparent', 86 + 'w-full [appearance:textfield] border-0 bg-transparent py-2 text-center font-[inherit] text-3xl font-semibold text-transparent outline-none [-moz-appearance:_textfield] focus-visible:ring-0 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' 87 + )} 88 + style="font-kerning: none" 89 + type="number" 90 + {min} 91 + step="1" 92 + autocomplete="off" 93 + inputmode="numeric" 94 + {max} 95 + {value} 96 + oninput={handleInput} 97 + bind:this={inputRef} 98 + /> 99 + <NumberFlow 100 + {value} 101 + locales="en-US" 102 + format={{ useGrouping: false }} 103 + aria-hidden="true" 104 + {animated} 105 + on:animationsstart={() => (showCaret = false)} 106 + on:animationsfinish={() => (showCaret = true)} 107 + class="pointer-events-none" 108 + willChange 109 + /> 110 + </div> 111 + <button 112 + aria-hidden="true" 113 + tabindex="-1" 114 + class="flex cursor-pointer items-center pr-[.5em] pl-[.325em] disabled:cursor-not-allowed disabled:opacity-50 hover:text-accent-600 dark:hover:text-accent-400 disabled:hover:text-accent-500 dark:disabled:hover:text-accent-500 transition-colors" 115 + disabled={max != null && value >= max} 116 + onpointerdown={(event) => handlePointerDown(event, step)} 117 + > 118 + <svg 119 + xmlns="http://www.w3.org/2000/svg" 120 + fill="none" 121 + viewBox="0 0 24 24" 122 + stroke-width="3.5" 123 + stroke="currentColor" 124 + class="size-4" 125 + > 126 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 127 + </svg> 128 + </button> 129 + </div>
+49
src/lib/components/timer/Timer.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import { TimerState } from './TimerState.svelte'; 4 + import type { WithElementRef, WithoutChildrenOrChild } from 'bits-ui'; 5 + import type { HTMLAttributes } from 'svelte/elements'; 6 + import { cn } from '$lib/utils.js'; 7 + 8 + let { 9 + timer = $bindable(new TimerState(1000 * 60 * 60)), 10 + class: className, 11 + ref = $bindable(null), 12 + ...restProps 13 + }: WithElementRef<WithoutChildrenOrChild<HTMLAttributes<HTMLDivElement>>> & { 14 + timer?: TimerState; 15 + } = $props(); 16 + 17 + const hh = $derived(Math.floor(timer.remaining / 3600000)); 18 + const mm = $derived(Math.floor((timer.remaining % 3600000) / 60000)); 19 + const ss = $derived(Math.floor((timer.remaining % 60000) / 1000)); 20 + </script> 21 + 22 + <NumberFlowGroup> 23 + <div 24 + bind:this={ref} 25 + class={cn( 26 + 'text-base-900 dark:text-base-100 flex w-full justify-center text-5xl font-bold sm:text-7xl', 27 + className 28 + )} 29 + style="font-variant-numeric: tabular-nums;" 30 + {...restProps} 31 + > 32 + <NumberFlow value={hh} trend={-1} format={{ minimumIntegerDigits: 2 }} /> 33 + 34 + <NumberFlow 35 + value={mm} 36 + trend={-1} 37 + format={{ minimumIntegerDigits: 2 }} 38 + prefix=":" 39 + digits={{ 1: { max: 5 } }} 40 + /> 41 + <NumberFlow 42 + value={ss} 43 + format={{ minimumIntegerDigits: 2 }} 44 + trend={-1} 45 + prefix=":" 46 + digits={{ 1: { max: 5 } }} 47 + /> 48 + </div> 49 + </NumberFlowGroup>
+100
src/lib/components/timer/TimerState.svelte.ts
··· 1 + // adopted from https://github.com/joshnuss/svelte-reactive-timer 2 + 3 + type Status = 'running' | 'paused' | 'stopped'; 4 + type Options = { 5 + precision?: number; 6 + }; 7 + 8 + export class TimerState { 9 + status = $state<Status>('stopped'); 10 + duration = $state<number>(0); 11 + elapsed = $state<number>(0); 12 + remaining = $derived(this.duration - this.elapsed); 13 + startAt = $state<Date | null>(null); 14 + endAt = $state<Date | null>(null); 15 + time = $state<Date | null>(null); 16 + precision: number; 17 + #interval: number | null = null; 18 + 19 + constructor(duration: number, options: Options = {}) { 20 + this.duration = duration; 21 + this.precision = options.precision ?? 300; 22 + } 23 + 24 + get isRunning() { 25 + return this.status == 'running'; 26 + } 27 + 28 + get isStopped() { 29 + return this.status == 'stopped'; 30 + } 31 + 32 + get isPaused() { 33 + return this.status == 'paused'; 34 + } 35 + 36 + start() { 37 + this.time = new Date(); 38 + this.startAt = this.time; 39 + this.endAt = new Date(this.time.getTime() + this.duration); 40 + this.status = 'running'; 41 + this.elapsed = 0; 42 + this.#schedule(); 43 + } 44 + 45 + stop() { 46 + this.#dispose(); 47 + this.status = 'stopped'; 48 + } 49 + 50 + pause() { 51 + this.#dispose(); 52 + this.status = 'paused'; 53 + } 54 + 55 + resume() { 56 + this.time = new Date(); 57 + this.endAt = new Date(this.time.getTime() + this.remaining); 58 + this.status = 'running'; 59 + this.#schedule(); 60 + } 61 + 62 + reset(duration?: number) { 63 + if (duration) { 64 + this.duration = duration; 65 + } 66 + this.#dispose(); 67 + 68 + if (!this.isStopped) { 69 + this.start(); 70 + } else { 71 + this.time = new Date(); 72 + this.startAt = this.time; 73 + this.endAt = new Date(this.time.getTime() + this.duration); 74 + this.elapsed = 0; 75 + } 76 + } 77 + 78 + #schedule() { 79 + this.time = new Date(); 80 + this.#interval = setInterval(() => this.#onInterval(), this.precision); 81 + } 82 + 83 + #dispose() { 84 + if (this.#interval) { 85 + clearInterval(this.#interval); 86 + } 87 + } 88 + 89 + #onInterval() { 90 + const now = new Date(); 91 + 92 + this.elapsed += now.getTime() - this.time!.getTime(); 93 + this.time = now; 94 + 95 + if (this.time >= this.endAt!) { 96 + this.elapsed = this.duration; 97 + this.stop(); 98 + } 99 + } 100 + }
+5
src/routes/components/number-input/+page.svelte
··· 1 + <script> 2 + import NumberInputPreview from '$lib/preview/NumberInputPreview.svelte'; 3 + </script> 4 + 5 + <NumberInputPreview />
+5
src/routes/components/social-icons/+page.svelte
··· 1 + <script> 2 + import SocialIconsPreview from '$lib/preview/SocialIconsPreview.svelte'; 3 + </script> 4 + 5 + <SocialIconsPreview />
+5
src/routes/components/timer/+page.svelte
··· 1 + <script> 2 + import TimerPreview from '$lib/preview/TimerPreview.svelte'; 3 + </script> 4 + 5 + <TimerPreview />