How to build a sorting table with images using Alpine.js and Tailwind CSS

Learn how to create a sortable table with avatars, flags, and dynamic sorting using Alpine.js and Tailwind CSS.

Published on October 31, 2025 by Michael Andreuzza

Let’s build a sortable, image‑rich table

Sorting tables are a staple of modern dashboards and admin panels. This guide shows you how to build a clean, responsive table with avatars, emoji flags, and custom sorting using Alpine.js and Tailwind CSS—no backend needed.

Heads up: Make sure Tailwind CSS and Alpine.js are already set up in your project. You can drop the snippet into any Astro/HTML page and it just works.

1. Component structure and data

We’ll use a single Alpine component to manage state (current sort key/direction) and handle sorting. Each row includes:

  • name, email
  • avatar image
  • location object: city, country, flag emoji
  • status and performance labels

Data shape

<div
  x-data="{
    sortKey: 'name',
    sortDir: 'asc',
    collator: new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
    statusRank: { Active: 2, Inactive: 1 },
    performanceRank: { Excellent: 3, 'Very Good': 2, Good: 1 },
    rows: [] // your data array; see full demo at the end
  }"
>
  <!-- Table markup covered below -->
  <!-- Sorting + resolver methods shown in the next sections -->
</div>

2. Sorting controls

Clicking a header calls setSort(key):

setSort(key){
  if (this.sortKey === key) {
    this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
  } else {
    this.sortKey = key;
    this.sortDir = 'asc';
  }
}
  • Clicking the same column toggles direction
  • Clicking a different column starts at ascending

3. Robust sorting (strings, labels, and diacritics)

getValue() normalizes values before compare. Location sorts by city; status and performance use numeric ranks so “Excellent” > “Very Good” > “Good”. For strings, Intl.Collator gives better results for names like “São Paulo”.

getValue(row, key){
  if (key === 'location') return row.location.city;
  if (key === 'status') return this.statusRank[row.status] ?? 0;
  if (key === 'performance') return this.performanceRank[row.performance] ?? 0;
  return row[key];
}

sorted(){
  const a = [...this.rows];
  const k = this.sortKey;
  const dir = this.sortDir === 'asc' ? 1 : -1;
  return a.sort((x, y) => {
    const vx = this.getValue(x, k);
    const vy = this.getValue(y, k);
    const cmp = (typeof vx === 'number' && typeof vy === 'number')
      ? (vx - vy)
      : this.collator.compare(String(vx), String(vy));
    return cmp * dir;
  });
}

Collation and diacritics

  • Intl.Collator handles locale rules, case, and accents better than manual toLowerCase() compares.
  • The numeric: true option sorts embedded numbers naturally (e.g., v2 < v10).
  • Passing undefined uses the user’s locale; set a specific one (e.g., en) if you need consistent ordering across regions.

Sorting categorical labels

  • Labels like Status/Performance don’t have an inherent order; map them to numbers with rank objects and sort the numbers.
  • Keep the rank maps (statusRank, performanceRank) outside the sort function to avoid re-creating them.

Tie‑breakers and nulls

Add a tie‑breaker so stable sorting doesn’t “jump” when values are equal, and push null/undefined to the end:

sorted(){
  const a = [...(this.filtered ? this.filtered() : this.rows)];
  const k = this.sortKey;
  const dir = this.sortDir === 'asc' ? 1 : -1;
  return a.sort((x, y) => {
    const vx = this.getValue(x, k);
    const vy = this.getValue(y, k);

    // Nulls last
    if (vx == null && vy == null) return 0;
    if (vx == null) return 1;
    if (vy == null) return -1;

    const cmp = (typeof vx === 'number' && typeof vy === 'number')
      ? (vx - vy)
      : this.collator.compare(String(vx), String(vy));
    if (cmp !== 0) return cmp * dir;

    // Tie-breaker by email for stability
    return this.collator.compare(x.email, y.email) * dir;
  });
}

4. Markup and accessibility

  • Use scope="col" on headers and aria-sort to expose sort state
  • Keep buttons inside th for keyboard/screen reader support
  • Provide alt text for avatars using the user’s name

5. Responsive layout and styling

  • Wrap the table with overflow-x-auto to enable horizontal scroll on small screens
  • Use min-w-[900px] to prevent cramped columns
  • Zebra striping with even:bg-zinc-50 improves readability

Performance tips

  • Keep collator as a single instance on the component (as shown) to avoid re-creating it inside sorted().
  • Sort a copy ([...rows]) so Alpine can detect changes without mutating your source array.
  • For very large lists, consider precomputing per‑row sort keys or using virtualization; the compare logic remains the same.
  • Reset to a known page when sorting if you add pagination later (e.g., page = 1).

Want quick filtering? Add a query property and a small filter:

<input
  type="search"
  placeholder="Search…"
  x-model="query"
  class="mb-2 w-full max-w-xs rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-800 placeholder-zinc-400"
/>
query: '',
filtered(){
  const q = this.query.trim().toLowerCase();
  if (!q) return this.rows;
  return this.rows.filter(r => [
    r.name,
    r.email,
    r.location.city,
    r.location.country,
    r.status,
    r.performance,
  ].some(v => String(v).toLowerCase().includes(q)));
},
sorted(){
  const a = [...this.filtered()];
  // … use the same compare logic as above
}

Bonus: Complete working demo

Here’s the full component assembled. Copy, paste, and customize the rows array and classes as needed.

<div
  x-data="{
    sortKey: 'name',
    sortDir: 'asc',
    
    // Optional: locale‑aware string compare
    collator: new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
    
    // Custom rank for label sorting
    statusRank: { Active: 2, Inactive: 1 },
    performanceRank: { Excellent: 3, 'Very Good': 2, Good: 1 },

    rows: [
      { name: 'Alex Allan', avatar: '/avatars/1.jpg', email: 'alex.allan@company.com', location: { country: 'Brazil', city: 'São Paulo', flag: '🇧🇷' }, status: 'Active', performance: 'Excellent' },
      { name: 'Alex Thompson', avatar: '/avatars/2.jpg', email: 'a.tompson@company.com', location: { country: 'US', city: 'San Francisco', flag: '🇺🇸' }, status: 'Inactive', performance: 'Excellent' },
      { name: 'Anna Visconti', avatar: '/avatars/3.jpg', email: 'anna.visconti@company.com', location: { country: 'Italy', city: 'Rome', flag: '🇮🇹' }, status: 'Active', performance: 'Good' },
      { name: 'Astrid Andersen', avatar: '/avatars/4.jpg', email: 'a.andersen@company.com', location: { country: 'Norway', city: 'Oslo', flag: '🇳🇴' }, status: 'Inactive', performance: 'Good' },
      { name: 'Cheng Wei', avatar: '/avatars/5.jpg', email: 'c.wei@company.com', location: { country: 'China', city: 'Shanghai', flag: '🇨🇳' }, status: 'Active', performance: 'Excellent' },
      { name: 'David Kim', avatar: '/avatars/6.jpg', email: 'd.kim@company.com', location: { country: 'France', city: 'Paris', flag: '🇫🇷' }, status: 'Active', performance: 'Very Good' },
      { name: 'Diego Mendoza', avatar: '/avatars/7.jpg', email: 'd.mendoza@company.com', location: { country: 'Mexico', city: 'Mexico City', flag: '🇲🇽' }, status: 'Active', performance: 'Good' },
      { name: 'Emma Laurent', avatar: '/avatars/8.jpg', email: 'e.laurent@company.com', location: { country: 'Germany', city: 'Berlin', flag: '🇩🇪' }, status: 'Active', performance: 'Very Good' },
      { name: 'Eva Kowalski', avatar: '/avatars/9.jpg', email: 'e.kowalski@company.com', location: { country: 'South Korea', city: 'Seoul', flag: '🇰🇷' }, status: 'Active', performance: 'Good' },
      { name: 'Fatima Al-Sayed', avatar: '/avatars/10.jpg', email: 'f.alsayed@company.com', location: { country: 'Egypt', city: 'Cairo', flag: '🇪🇬' }, status: 'Active', performance: 'Excellent' },
    ],
    
    // 2) Sorting controls
    setSort(key){
      if (this.sortKey === key) {
        this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
      } else {
        this.sortKey = key;
        this.sortDir = 'asc';
      }
    },

    // 3) Value resolver for different column types
    getValue(row, key){
      if (key === 'location') return row.location.city;
      if (key === 'status') return this.statusRank[row.status] ?? 0;
      if (key === 'performance') return this.performanceRank[row.performance] ?? 0;
      return row[key];
    },

    // 4) Sorted rows (locale‑aware + numeric where needed)
    sorted(){
      const a = [...this.rows];
      const k = this.sortKey;
      const dir = this.sortDir === 'asc' ? 1 : -1;
      return a.sort((x, y) => {
        const vx = this.getValue(x, k);
        const vy = this.getValue(y, k);
        const cmp = (typeof vx === 'number' && typeof vy === 'number')
          ? (vx - vy)
          : this.collator.compare(String(vx), String(vy));
        return cmp * dir;
      });
    },

    pageRows(){ return this.sorted(); },
  }"
  class="mx-auto flow-root max-w-7xl"
>
  <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
    <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
      <div class="flex justify-center">
        <table class="min-w-[900px] text-sm bg-white" role="table">
          <thead class="border-b bg-zinc-100 border-zinc-200" role="rowgroup">
            <tr role="row">
              <th
                scope="col"
                class="px-3 py-2 font-medium text-left text-zinc-700"
                :aria-sort="sortKey==='name' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
              >
                <button
                  class="inline-flex items-center gap-1 hover:underline text-zinc-900"
                  @click="setSort('name')"
                >
                  Name
                  <template x-if="sortKey==='name'">
                    <span>
                      <template x-if="sortDir==='asc'">
                        <svg
                          xmlns="http://www.w3.org/2000/svg"
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          stroke-width="2"
                          stroke-linecap="round"
                          stroke-linejoin="round"
                          class="icon icon-tabler-chevron-up size-4"
                        >
                          <path
                            stroke="none"
                            d="M0 0h24v24H0z"
                            fill="none"
                          ></path>
                          <path d="M6 15l6 -6l6 6"></path>
                        </svg>
                      </template>
                      <template x-if="sortDir==='desc'">
                        <svg
                          xmlns="http://www.w3.org/2000/svg"
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          stroke-width="2"
                          stroke-linecap="round"
                          stroke-linejoin="round"
                          class="icon icon-tabler-chevron-down size-4"
                        >
                          <path
                            stroke="none"
                            d="M0 0h24v24H0z"
                            fill="none"
                          ></path>
                          <path d="M6 9l6 6l6 -6"></path>
                        </svg>
                      </template>
                    </span>
                  </template>
                </button>
              </th>
              <th
                scope="col"
                class="px-3 py-2 font-medium text-left text-zinc-700"
                :aria-sort="sortKey==='email' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
              >
                <button
                  class="inline-flex items-center gap-1 hover:underline text-zinc-900"
                  @click="setSort('email')"
                >
                  Email
                  <span
                    x-text="sortKey==='email' ? (sortDir==='asc'?'▲':'▼') : ''"
                  ></span>
                </button>
              </th>
              <th
                scope="col"
                class="px-3 py-2 font-medium text-left text-zinc-700"
                :aria-sort="sortKey==='location' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
              >
                <button
                  class="inline-flex items-center gap-1 hover:underline text-zinc-900"
                  @click="setSort('location')"
                >
                  Location
                  <span
                    x-text="sortKey==='location' ? (sortDir==='asc'?'▲':'▼') : ''"
                  ></span>
                </button>
              </th>
              <th
                scope="col"
                class="px-3 py-2 font-medium text-left text-zinc-700"
                :aria-sort="sortKey==='status' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
              >
                <button
                  class="inline-flex items-center gap-1 hover:underline text-zinc-900"
                  @click="setSort('status')"
                >
                  Status
                  <span
                    x-text="sortKey==='status' ? (sortDir==='asc'?'▲':'▼') : ''"
                  ></span>
                </button>
              </th>
              <th
                scope="col"
                class="px-3 py-2 font-medium text-left text-zinc-700"
                :aria-sort="sortKey==='performance' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
              >
                <button
                  class="inline-flex items-center gap-1 hover:underline text-zinc-900"
                  @click="setSort('performance')"
                >
                  Performance
                  <span
                    x-text="sortKey==='performance' ? (sortDir==='asc'?'▲':'▼') : ''"
                  ></span>
                </button>
              </th>
            </tr>
          </thead>
          <tbody role="rowgroup">
            <template x-for="row in pageRows()" :key="row.email">
              <tr class="even:bg-zinc-50" role="row">
                <td class="px-3 py-2 text-zinc-700" role="cell">
                  <div class="flex items-center gap-2">
                    <img
                      class="object-cover w-8 h-8 rounded-full"
                      :src="row.avatar"
                      :alt="row.name"
                    />
                    <span class="font-medium" x-text="row.name"></span>
                  </div>
                </td>
                <td
                  class="px-3 py-2 text-zinc-500"
                  role="cell"
                  x-text="row.email"
                ></td>
                <td class="px-3 py-2 text-zinc-500" role="cell">
                  <span class="inline-flex items-center gap-1">
                    <span aria-hidden="true" x-text="row.location.flag"></span>
                    <span
                      x-text="row.location.city + ', ' + row.location.country"
                    ></span>
                  </span>
                </td>
                <td
                  class="px-3 py-2"
                  role="cell"
                  :class="row.status==='Active' ? 'text-emerald-600' : row.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
                  x-text="row.status"
                ></td>
                <td
                  class="px-3 py-2 text-zinc-500"
                  role="cell"
                  x-text="row.performance"
                ></td>
              </tr>
            </template>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</div>

What you get

  • Click‑to‑sort headers with visual indicators
  • Avatars + emoji flags per row
  • Locale‑aware sorting that handles diacritics
  • Accessible markup that announces sort order

Use this pattern for team lists, customer directories, or any sortable, image‑rich table.

/Michael Andreuzza

Did you like this post? Please share it with your friends!