Skip to content

The interface of our blog: UX decisions for authors and readers

Reusable Vue components, filtering, a preview mode for authors, responsive layout with Tailwind and accessibility: the UX choices behind the blog and what changed after launch.

AuthorIT Department
  • 8 min read
  • 186 views
The interface of our blog: UX decisions for authors and readers

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:

  1. Minimum clicks. Any action should take at most three clicks.

  2. Visual hierarchy. Important things should stand out.

  3. 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

  1. Consistency beats originality. People value predictability.

  2. Small interactions signal quality. Details matter.

  3. Performance is part of UX. A fast interface feels convenient.

  4. 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.

Tags

Share this article

Back to blog

Cookies

We use essential cookies and one functional cookie, the latter only with your consent. Visit statistics are counted on our server, without cookies. Cookie Policy