See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add animations to landing page, loading page, and shared components

Staggered fade-in entrance on landing page elements with a heart pulse
on the logo. Shimmer effect on the backfill progress bar. Smooth
transitions on search input focus, Go button press, and typeahead
dropdown open/close. Larger header logo. Includes prefers-reduced-motion
support to disable all animations for accessibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+102 -23
+73 -2
resources/css/app.css
··· 57 57 /* ── Tinted neutral (embed borders) ── */ 58 58 --color-neutral-200: oklch(92% 0.015 255); 59 59 60 + /* ── Easing ── */ 61 + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); 62 + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); 63 + 60 64 /* ── Animation ── */ 61 65 --animate-scale-up: scale-up 0.2s cubic-bezier(0.39, 0.575, 0.565, 1) both; 66 + --animate-fade-in-up: fade-in-up 0.5s var(--ease-out-quart) both; 67 + --animate-fade-in: fade-in 0.4s var(--ease-out-quart) both; 62 68 } 63 69 64 70 @keyframes scale-up { 71 + from { transform: scale(0.7); } 72 + to { transform: scale(1); } 73 + } 74 + 75 + @keyframes fade-in-up { 65 76 from { 66 - transform: scale(0.7); 77 + opacity: 0; 78 + transform: translateY(12px); 67 79 } 68 80 to { 69 - transform: scale(1); 81 + opacity: 1; 82 + transform: translateY(0); 83 + } 84 + } 85 + 86 + @keyframes fade-in { 87 + from { opacity: 0; } 88 + to { opacity: 1; } 89 + } 90 + 91 + @keyframes heart-pulse { 92 + 0%, 100% { transform: scale(1); } 93 + 50% { transform: scale(1.18); } 94 + } 95 + 96 + @keyframes shimmer { 97 + from { transform: translateX(-100%); } 98 + to { transform: translateX(100%); } 99 + } 100 + 101 + @keyframes glow-in { 102 + from { 103 + opacity: 0; 104 + transform: translateY(12px); 105 + box-shadow: 0 0 0 0 transparent; 106 + } 107 + 60% { 108 + opacity: 1; 109 + transform: translateY(0); 110 + } 111 + 100% { 112 + opacity: 1; 113 + transform: translateY(0); 114 + box-shadow: 0 0 20px -4px oklch(85% 0.08 85 / 0.5); 115 + } 116 + } 117 + 118 + @keyframes glow-in-blue { 119 + from { 120 + opacity: 0; 121 + transform: translateY(12px); 122 + box-shadow: 0 0 0 0 transparent; 123 + } 124 + 60% { 125 + opacity: 1; 126 + transform: translateY(0); 127 + } 128 + 100% { 129 + opacity: 1; 130 + transform: translateY(0); 131 + box-shadow: 0 0 20px -4px oklch(85% 0.08 250 / 0.5); 70 132 } 71 133 } 72 134 ··· 75 137 display: none !important; 76 138 } 77 139 } 140 + 141 + /* ── Reduced motion ── */ 142 + @media (prefers-reduced-motion: reduce) { 143 + *, *::before, *::after { 144 + animation-duration: 0.01ms !important; 145 + animation-iteration-count: 1 !important; 146 + transition-duration: 0.01ms !important; 147 + } 148 + }
+3 -3
resources/views/components/layout.edge
··· 30 30 <div class="max-w-[680px] mx-auto px-4"> 31 31 @if(!$props.has('hideHeader')) 32 32 <div class="flex items-center justify-between pt-6 pb-4"> 33 - <a href="/" class="flex items-center gap-1.5 text-gray-900 dark:text-gray-100 no-underline hover:opacity-70"> 34 - <i class="ph-fill ph-heart text-red-500 text-lg"></i> 35 - <span class="font-heading font-bold text-sm tracking-tight">favs.blue</span> 33 + <a href="/" class="flex items-center gap-1.5 text-gray-900 dark:text-gray-100 no-underline hover:opacity-70 transition-opacity duration-200"> 34 + <i class="ph-fill ph-heart text-red-500 text-2xl"></i> 35 + <span class="font-heading font-bold text-lg tracking-tight">favs.blue</span> 36 36 </a> 37 37 <div class="flex items-center gap-2"> 38 38 <form action="/search" method="GET">
+8 -2
resources/views/components/search_ahead.edge
··· 13 13 placeholder="{{ size === 'sm' ? '@handle' : '@handle or handle.bsky.social' }}" 14 14 autocomplete="off" 15 15 {{ autofocusAttr ? 'autofocus' : '' }} 16 - class="{{ size === 'sm' ? 'w-44 px-2.5 py-1.5 text-sm' : 'flex-1 px-3.5 py-2.5 text-base' }} border border-gray-300 dark:border-gray-700 rounded-md outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" 16 + class="{{ size === 'sm' ? 'w-44 px-2.5 py-1.5 text-sm' : 'flex-1 px-3.5 py-2.5 text-base' }} border border-gray-300 dark:border-gray-700 rounded-md outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 transition-[border-color,box-shadow] duration-200" 17 17 /> 18 18 <button 19 19 type="submit" 20 - class="{{ size === 'sm' ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base' }} bg-blue-600 text-white border-none rounded-md cursor-pointer hover:bg-blue-700" 20 + class="{{ size === 'sm' ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base' }} bg-blue-600 text-white border-none rounded-md cursor-pointer hover:bg-blue-700 transition-[background-color,transform] duration-150 active:scale-95" 21 21 >Go</button> 22 22 </div> 23 23 24 24 <div 25 25 x-show="open" 26 26 x-cloak 27 + x-transition:enter="transition ease-out duration-150" 28 + x-transition:enter-start="opacity-0 -translate-y-1" 29 + x-transition:enter-end="opacity-100 translate-y-0" 30 + x-transition:leave="transition ease-in duration-100" 31 + x-transition:leave-start="opacity-100 translate-y-0" 32 + x-transition:leave-end="opacity-0 -translate-y-1" 27 33 class="absolute z-50 mt-1 {{ size === 'sm' ? 'min-w-56 right-0' : 'w-full' }} bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden" 28 34 > 29 35 <template x-for="(actor, index) in actors">
+7 -7
resources/views/pages/landing.edge
··· 1 1 @component('components/layout', { hideHeader: true }) 2 2 @slot('main') 3 3 <div class="pt-16 pb-12"> 4 - <div class="flex items-center justify-between mb-2"> 4 + <div class="flex items-center justify-between mb-2 animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]"> 5 5 <h1 class="font-heading text-4xl font-bold flex items-center gap-2 tracking-tight"> 6 - <i class="ph-fill ph-heart text-red-500"></i> 6 + <i class="ph-fill ph-heart text-red-500 inline-block animate-[heart-pulse_2s_var(--ease-out-quart)_1.2s_both]"></i> 7 7 favs.blue 8 8 </h1> 9 9 <button 10 10 x-data="darkMode" 11 11 x-on:click="toggle" 12 - class="p-1.5 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 cursor-pointer" 12 + class="p-1.5 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 cursor-pointer transition-colors duration-200" 13 13 aria-label="Toggle dark mode" 14 14 > 15 15 <i x-show="!dark" class="ph-fill ph-moon text-base"></i> 16 16 <i x-show="dark" class="ph-fill ph-sun text-base"></i> 17 17 </button> 18 18 </div> 19 - <p class="font-heading text-lg text-gray-600 dark:text-gray-400 mb-10"> 19 + <p class="font-heading text-lg text-gray-600 dark:text-gray-400 mb-10 animate-[fade-in-up_0.5s_var(--ease-out-quart)_0.08s_both]"> 20 20 See any Bluesky account's most popular posts. 21 21 </p> 22 22 23 - <form action="/search" method="GET" class="mb-4"> 23 + <form action="/search" method="GET" class="mb-4 relative z-10 animate-[fade-in-up_0.5s_var(--ease-out-quart)_0.16s_both]"> 24 24 @component('components/search_ahead', { autofocus: true }) 25 25 @endcomponent 26 26 </form> ··· 29 29 <p class="text-red-700 dark:text-red-400 mb-4 text-sm font-medium">{{ error }}</p> 30 30 @endif 31 31 32 - <p class="text-sm text-gray-400 dark:text-gray-500 mb-8"> 32 + <p class="text-sm text-gray-400 dark:text-gray-500 mb-8 animate-[fade-in-up_0.5s_var(--ease-out-quart)_0.24s_both]"> 33 33 Try an example: 34 34 @each(handle in examples) 35 35 <a href="/profile/{{ handle }}/likes" class="mr-3 text-blue-600 dark:text-blue-400 hover:underline">{{ handle }}</a> 36 36 @endeach 37 37 </p> 38 38 39 - <p class="text-sm text-gray-500 dark:text-gray-400 max-w-[520px]"> 39 + <p class="text-sm text-gray-500 dark:text-gray-400 max-w-[520px] animate-[fade-in-up_0.5s_var(--ease-out-quart)_0.32s_both]"> 40 40 favs.blue indexes Bluesky posts and tracks engagement from the 41 41 <a href="https://atproto.com" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">AT Protocol</a> firehose. 42 42 Type a handle to see that account's greatest hits.
+7 -5
resources/views/pages/profile/loading.edge
··· 16 16 class="py-16 text-center" 17 17 > 18 18 <div x-show="state !== 'failed'"> 19 - <h1 id="backfill-title" class="text-2xl mb-4"> 19 + <h1 id="backfill-title" class="text-2xl mb-4 animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]"> 20 20 Indexing {{ '@' + handle }}… 21 21 </h1> 22 - <p class="text-gray-500 dark:text-gray-400 max-w-[420px] mx-auto mb-6"> 22 + <p class="text-gray-500 dark:text-gray-400 max-w-[420px] mx-auto mb-6 animate-[fade-in-up_0.5s_var(--ease-out-quart)_0.08s_both]"> 23 23 This is a one-time index. It may take a few minutes for very active accounts. 24 24 </p> 25 25 <div ··· 28 28 aria-valuemin="0" 29 29 aria-valuemax="{{ totalPosts }}" 30 30 aria-labelledby="backfill-title" 31 - class="w-80 max-w-[80%] h-3.5 mx-auto rounded-full bg-blue-100 dark:bg-blue-950 overflow-hidden" 31 + class="w-80 max-w-[80%] h-3.5 mx-auto rounded-full bg-blue-100 dark:bg-blue-950 overflow-hidden animate-[fade-in_0.4s_var(--ease-out-quart)_0.16s_both]" 32 32 > 33 33 <div 34 - class="h-full rounded-full bg-blue-600 transition-[width] duration-500 ease-out" 34 + class="h-full rounded-full bg-blue-600 transition-[width] duration-500 ease-out relative overflow-hidden" 35 35 x-bind:style="progressWidth" 36 - ></div> 36 + > 37 + <div class="absolute inset-0 animate-[shimmer_1.5s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/25 to-transparent"></div> 38 + </div> 37 39 </div> 38 40 <p 39 41 aria-live="polite"
+4 -4
resources/views/pages/profile/show.edge
··· 26 26 <div class="flex gap-1"> 27 27 <a 28 28 href="/profile/{{ handle }}/likes{{ daysWindow ? '?days=' + daysWindow : '' }}" 29 - class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'likes' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 29 + class="px-3.5 py-1.5 rounded-full text-sm transition-colors duration-200 {{ kind === 'likes' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 30 30 >Most liked</a> 31 31 <a 32 32 href="/profile/{{ handle }}/reposts{{ daysWindow ? '?days=' + daysWindow : '' }}" 33 - class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'reposts' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 33 + class="px-3.5 py-1.5 rounded-full text-sm transition-colors duration-200 {{ kind === 'reposts' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 34 34 >Most reposted</a> 35 35 </div> 36 36 ··· 38 38 <div class="flex gap-1"> 39 39 <a 40 40 href="/profile/{{ handle }}/{{ kind }}" 41 - class="px-3.5 py-1.5 rounded-full text-sm {{ !daysWindow ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 41 + class="px-3.5 py-1.5 rounded-full text-sm transition-colors duration-200 {{ !daysWindow ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 42 42 >All time</a> 43 43 <a 44 44 href="/profile/{{ handle }}/{{ kind }}?days=30" 45 - class="px-3.5 py-1.5 rounded-full text-sm {{ daysWindow === 30 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 45 + class="px-3.5 py-1.5 rounded-full text-sm transition-colors duration-200 {{ daysWindow === 30 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 46 46 >Last month</a> 47 47 </div> 48 48 </div>