The first three articles covered the technical side of the blog. Even a perfect architecture is wasted if people find the product awkward to use. This article is about the interface, for authors and for readers.
Design philosophy: simple and functional
Our UX work rests on three principles:
Minimum clicks. Any action should take at most three clicks.
Visual hierarchy. Important things should stand out.
Responsiveness. The interface reacts to the user instantly.
Vue.js component architecture
We built a set of reusable components. This one handles multilingual category selection:
<template>
<div class="space-y-2">
<div class="flex mb-3 content-center">
<FormLabel class="block !mb-0 font-medium text-slate-700 dark:text-slate-300">
{{ props.label }}
</FormLabel>
</div>
<div class="relative multiselect-container">
<button
type="button"
@click="isOpen = !isOpen"
class="relative w-full min-h-[42px] px-3 py-2 text-left
bg-white dark:bg-darkmode-700 border rounded-md shadow-sm"
:class="{
'ring-4 ring-primary ring-opacity-20': isOpen,
'border-red-300': error
}"
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div v-if="selectedValues.length === 0" class="text-slate-400/90">
{{ $trans('main.shared.select') }}
</div>
<div v-else class="flex flex-wrap gap-1">
<span
v-for="(categoryId, index) in displayedValues"
:key="`${categoryId}-${index}`"
class="inline-flex items-center gap-1 px-2 py-1 text-xs
font-medium bg-primary/10 text-primary rounded-md"
>
{{ getCategoryName(categoryId) }}
<button
type="button"
@click.stop="removeCategory(categoryId)"
class="ml-1 h-3 w-3 hover:bg-primary/20 rounded-sm"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<span v-if="hiddenCount > 0" class="inline-flex items-center px-2 py-1 text-xs">
+{{ hiddenCount }} {{ $trans('main.shared.more') }}
</span>
</div>
</div>
<svg
class="h-5 w-5 text-slate-400 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
>
<div v-if="isOpen" class="absolute z-50 w-full mt-1 bg-white rounded-md shadow-lg">
<!-- Search -->
<div class="p-3 border-b">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
:placeholder="$trans('main.shared.search')"
class="w-full pl-9 pr-3 py-2 text-sm border rounded-md"
/>
</div>
<!-- Options list -->
<div class="max-h-48 overflow-y-auto">
<label
v-for="category in filteredLabels"
:key="category.id"
class="flex items-center px-3 py-2 hover:bg-slate-50 cursor-pointer"
>
<input
type="checkbox"
:checked="isSelected(category.id)"
@change="toggleSelection(category.id)"
class="h-4 w-4 rounded"
/>
<span class="ml-3 text-sm font-medium">
{{ category.name }}
</span>
</label>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue';
const props = defineProps<{
label: string;
modelValue: number[];
availableLabels: Label[];
error?: string;
maxVisible?: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: number[]): void;
}>();
const isOpen = ref(false);
const searchQuery = ref('');
const searchInput = ref<HTMLInputElement>();
// Autofocus on search field when opened
watch(isOpen, async (newValue) => {
if (newValue) {
await nextTick();
searchInput.value?.focus();
} else {
searchQuery.value = '';
}
});
// Filtering considering current language
const filteredLabels = computed(() => {
if (!searchQuery.value) return props.availableLabels;
const query = searchQuery.value.toLowerCase();
return props.availableLabels.filter(label =>
label.name.toLowerCase().includes(query)
);
});
</script>
Filtering
Filtering was one of the harder tasks. It had to be both fast and easy to understand:
<template>
<div class="flex flex-col p-5 gap-y-4">
<!-- Main search -->
<div class="flex gap-2">
<div class="relative flex-1">
<Lucide icon="Search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" />
<FormInput
v-model="form.search"
type="text"
:placeholder="$trans('main.shared.search')"
class="pl-9"
/>
</div>
<!-- Advanced filters button -->
<Button
@click="showAdvancedFilters = !showAdvancedFilters"
variant="outline-secondary"
:class="{ 'bg-slate-100': showAdvancedFilters || hasActiveFilters }"
>
<Lucide icon="Filter" class="w-4 h-4 mr-2" />
{{ $trans('main.shared.buttons.filters') }}
<div v-if="activeFiltersCount > 0" class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs rounded-full">
{{ activeFiltersCount }}
</div>
</Button>
</div>
<!-- Active filters -->
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2">
<span
v-for="filter in activeFilterTags"
:key="filter.id"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium"
:class="filter.colorClass"
>
{{ filter.label }}: {{ filter.value }}
<button @click="removeFilter(filter.type)" class="ml-2 p-0.5">
<Lucide icon="X" class="w-3 h-3" />
</button>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, watch } from 'vue';
import { debounce } from 'lodash';
import { router } from '@inertiajs/vue3';
const form = reactive({
search: filters?.search || '',
status: filters?.status || '',
author_id: filters?.author_id || '',
category_id: filters?.category_id || '',
tag_ids: filters?.tag_ids || []
});
// Debounced search for optimization
const debouncedSearch = debounce(() => {
performSearch();
}, 500);
// Change tracking
watch(() => form.search, (newSearch) => {
if (newSearch.length >= 3 || newSearch.length === 0) {
debouncedSearch();
}
});
// Instant filter application
watch(() => [form.status, form.author_id, form.category_id], () => {
performSearch();
});
const performSearch = () => {
router.get(route('posts'), form, {
preserveState: true,
preserveScroll: true,
});
};
// Active filters visualization
const activeFilterTags = computed(() => {
const tags = [];
if (form.search) {
tags.push({
id: 'search',
type: 'search',
label: $trans('main.shared.search'),
value: `"${form.search}"`,
colorClass: 'bg-blue-100 text-blue-800'
});
}
if (form.status) {
tags.push({
id: 'status',
type: 'status',
label: $trans('main.blog.posts.status'),
value: $trans(`main.blog.posts.statuses.${form.status}`),
colorClass: 'bg-green-100 text-green-800'
});
}
return tags;
});
</script>
Preview mode for authors
Authors need to see how a post will look before it goes live, so we built a dedicated preview mode:
// Route for preview
Route::get('/blog/{language}/{slug}/preview', [PreviewController::class, 'show'])
->name('blog.preview')
->middleware(['auth', 'can:preview,post']);
// PreviewController
class PreviewController extends Controller
{
public function show(string $language, string $slug)
{
$content = Content::where('slug', $slug)
->where('language', $language)
->firstOrFail();
$post = $content->post()
->with(['author', 'categories', 'tags'])
->first();
// Access rights check
if (!auth()->user()->can('preview', $post)) {
abort(403);
}
// Special flag for template
$isPreview = true;
return view('blog.show', compact('post', 'content', 'isPreview'));
}
}
The frontend component with the preview indicator:
<template>
<div class="relative">
<!-- Preview indicator -->
<div v-if="isPreview" class="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white">
<div class="container mx-auto px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<Lucide icon="Eye" class="w-4 h-4" />
<span class="font-medium">{{ $trans('main.blog.preview_mode') }}</span>
</div>
<Button
@click="exitPreview"
variant="white"
size="sm"
>
{{ $trans('main.blog.exit_preview') }}
</Button>
</div>
</div>
<!-- Post content -->
<article :class="{ 'pt-12': isPreview }">
<!-- Regular content -->
</article>
</div>
</template>
Responsive design with Tailwind CSS
The layout is built on Tailwind's utility-first approach:
<template>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Main content -->
<div class="lg:col-span-8">
<div class="bg-white dark:bg-darkmode-700 rounded-lg shadow-sm p-4 md:p-6">
<!-- Mobile version of header -->
<div class="lg:hidden mb-4">
<h1 class="text-2xl font-bold">{{ post.title }}</h1>
</div>
<!-- Desktop version -->
<div class="hidden lg:block">
<h1 class="text-4xl font-bold mb-6">{{ post.title }}</h1>
</div>
<!-- Content -->
<div class="prose prose-sm md:prose-base lg:prose-lg max-w-none">
<div v-html="post.content_html"></div>
</div>
</div>
</div>
<!-- Sidebar -->
<aside class="lg:col-span-4">
<!-- Mobile version - collapsible -->
<div class="lg:hidden">
<button
@click="sidebarOpen = !sidebarOpen"
class="w-full flex items-center justify-between p-4 bg-white rounded-lg"
>
<span>{{ $trans('main.blog.more_info') }}</span>
<Lucide :icon="sidebarOpen ? 'ChevronUp' : 'ChevronDown'" />
</button>
<Transition>
<div v-if="sidebarOpen" class="mt-2">
<SidebarContent />
</div>
</Transition>
</div>
<!-- Desktop version - always visible -->
<div class="hidden lg:block sticky top-24">
<SidebarContent />
</div>
</aside>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useBreakpoints } from '@/mixins';
const { isMd, isLg } = useBreakpoints();
const sidebarOpen = ref(false);
// Automatically open sidebar on tablets
watch(isMd, (newValue) => {
if (newValue) {
sidebarOpen.value = true;
}
});
</script>
Composables for reusable logic
The Vue 3 Composition API let us pull shared logic into composables:
// composables/useInfiniteScroll.ts
export function useInfiniteScroll(
callback: () => void,
options: { threshold?: number; rootMargin?: string } = {}
) {
const target = ref<HTMLElement>();
const isLoading = ref(false);
const observer = new IntersectionObserver(
async ([entry]) => {
if (entry.isIntersecting && !isLoading.value) {
isLoading.value = true;
await callback();
isLoading.value = false;
}
},
{
threshold: options.threshold || 0.1,
rootMargin: options.rootMargin || '100px'
}
);
onMounted(() => {
if (target.value) {
observer.observe(target.value);
}
});
onUnmounted(() => {
observer.disconnect();
});
return {
target,
isLoading
};
}
// Usage in component
const { target, isLoading } = useInfiniteScroll(async () => {
const nextPage = posts.value.current_page + 1;
const response = await loadPosts(nextPage);
posts.value.data.push(...response.data);
});
Interactive elements
We added small animations to make the interface feel more responsive:
<template>
<button
@click="toggleLike"
class="group relative p-2 rounded-full transition-all duration-200"
:class="{ 'bg-red-50': isLiked }"
>
<Heart
:class="[
'w-5 h-5 transition-all duration-300',
isLiked
? 'fill-red-500 text-red-500 scale-110'
: 'text-gray-400 group-hover:text-red-400'
]"
/>
<!-- Click animation -->
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="scale-0 opacity-0"
enter-to-class="scale-150 opacity-0"
>
<div
v-if="showAnimation"
class="absolute inset-0 rounded-full bg-red-400"
/>
</Transition>
</button>
</template>
<script setup>
const isLiked = ref(false);
const showAnimation = ref(false);
const toggleLike = () => {
isLiked.value = !isLiked.value;
if (isLiked.value) {
showAnimation.value = true;
setTimeout(() => {
showAnimation.value = false;
}, 300);
}
};
</script>
Accessibility (a11y)
Accessibility got special attention:
<template>
<nav role="navigation" aria-label="Pagination">
<ul class="flex items-center gap-1">
<li>
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
:aria-label="$trans('pagination.previous')"
class="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50"
>
<ChevronLeft class="w-5 h-5" />
</button>
</li>
<li v-for="page in visiblePages" :key="page">
<button
@click="goToPage(page)"
:aria-current="page === currentPage ? 'page' : undefined"
:aria-label="`${$trans('pagination.go_to_page')} ${page}`"
class="px-3 py-1 rounded-md"
:class="{
'bg-primary text-white': page === currentPage,
'hover:bg-gray-100': page !== currentPage
}"
>
{{ page }}
</button>
</li>
</ul>
</nav>
</template>
Optimistic updates
To make the interface feel faster, we use optimistic updates:
const updatePost = async (data: UpdateData) => {
// Optimistic UI update
const oldData = { ...post.value };
post.value = { ...post.value, ...data };
try {
await api.updatePost(post.value.id, data);
toast.success($trans('main.blog.post_updated'));
} catch (error) {
// Rollback on error
post.value = oldData;
toast.error($trans('main.blog.update_failed'));
}
};
Results
After launching the updated interface, we saw this:
Bounce rate down by 35%
Time on site up by 45%
Subscriber conversion up by 60%
Author NPS up from 6 to 9
What we learned
Consistency beats originality. People value predictability.
Small interactions signal quality. Details matter.
Performance is part of UX. A fast interface feels convenient.
Accessibility widens the audience. Do not forget users with limitations.
Takeaways
Good UX is less about attractive buttons and more about helping people finish their tasks with as little effort as possible. The interface we ended up with:
Works on all devices
Gives instant feedback
Anticipates what the user needs
Is accessible to everyone
The final article covers security and SEO: how we protected the system and made the content visible to search engines.
To be continued.



