Skip to main content

Overview

The JobCardSkeleton component (exported from the Loading module) displays animated skeleton placeholders while job data is loading. It creates visual placeholders that match the structure of actual job cards.

Usage

import { JobCardSkeleton } from '@/components'

function JobsPage() {
  const { jobs, isLoading } = useJobs()
  
  if (isLoading) {
    return <JobCardSkeleton count={6} />
  }
  
  return <JobListings jobs={jobs} />
}

Props

count
number
default:3
The number of skeleton cards to display

Features

Customizable Count

The component renders the specified number of skeleton cards:
{Array.from({ length: count }).map((_, i) => (
  <div key={i} className={styles.skeletonCard}>
    {/* Skeleton content */}
  </div>
))}

Structured Skeleton

Each skeleton card mirrors the structure of a real JobCard:
  • Info section: Title, subtitle, description text
  • Actions section: Link and button placeholders
<div className={styles.skeletonCard}>
  <div className={styles.info}>
    <div className={`${styles.skeleton} ${styles.title}`}></div>
    <div className={`${styles.skeleton} ${styles.subtitle}`}></div>
    <div className={`${styles.skeleton} ${styles.text}`}></div>
    <div className={`${styles.skeleton} ${styles.textShort}`}></div>
  </div>
  <div className={styles.actions}>
    <div className={`${styles.skeleton} ${styles.link}`}></div>
    <div className={`${styles.skeleton} ${styles.button}`}></div>
  </div>
</div>

Component Structure

import styles from './Loading.module.css'

export function JobCardSkeleton({ count = 3 }) {
  return (
    <div className={styles.skeletonContainer}>
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className={styles.skeletonCard}>
          <div className={styles.info}>
            <div className={`${styles.skeleton} ${styles.title}`}></div>
            <div className={`${styles.skeleton} ${styles.subtitle}`}></div>
            <div className={`${styles.skeleton} ${styles.text}`}></div>
            <div className={`${styles.skeleton} ${styles.textShort}`}></div>
          </div>
          <div className={styles.actions}>
            <div className={`${styles.skeleton} ${styles.link}`}></div>
            <div className={`${styles.skeleton} ${styles.button}`}></div>
          </div>
        </div>
      ))}
    </div>
  )
}

Styling

The component uses CSS modules (Loading.module.css) for styling:

Main Classes

  • styles.skeletonContainer - Container for all skeleton cards
  • styles.skeletonCard - Individual skeleton card wrapper
  • styles.info - Info section container
  • styles.actions - Actions section container
  • styles.skeleton - Base skeleton styling (animation, colors)

Element Classes

  • styles.title - Title placeholder dimensions
  • styles.subtitle - Subtitle placeholder dimensions
  • styles.text - Full-width text placeholder
  • styles.textShort - Shorter text placeholder
  • styles.link - Link button placeholder
  • styles.button - Action button placeholder

Usage Patterns

With React Query

import { useQuery } from '@tanstack/react-query'
import { JobCardSkeleton, JobListings } from '@/components'

function JobsPage() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['jobs'],
    queryFn: fetchJobs
  })
  
  if (isLoading) return <JobCardSkeleton count={6} />
  if (error) return <div>Error loading jobs</div>
  
  return <JobListings jobs={data} />
}

With useState

import { useState, useEffect } from 'react'
import { JobCardSkeleton, JobListings } from '@/components'

function JobsPage() {
  const [jobs, setJobs] = useState([])
  const [isLoading, setIsLoading] = useState(true)
  
  useEffect(() => {
    fetchJobs()
      .then(setJobs)
      .finally(() => setIsLoading(false))
  }, [])
  
  if (isLoading) {
    return <JobCardSkeleton count={10} />
  }
  
  return <JobListings jobs={jobs} />
}

With Suspense

import { Suspense } from 'react'
import { JobCardSkeleton } from '@/components'
import { JobsListAsync } from './JobsListAsync'

function JobsPage() {
  return (
    <Suspense fallback={<JobCardSkeleton count={8} />}>
      <JobsListAsync />
    </Suspense>
  )
}

Pagination Loading

import { JobCardSkeleton, JobListings, Pagination } from '@/components'
import { useState, useEffect } from 'react'

function PaginatedJobs() {
  const [page, setPage] = useState(1)
  const [jobs, setJobs] = useState([])
  const [isLoading, setIsLoading] = useState(true)
  
  useEffect(() => {
    setIsLoading(true)
    fetchJobsPage(page)
      .then(setJobs)
      .finally(() => setIsLoading(false))
  }, [page])
  
  return (
    <>
      {isLoading ? (
        <JobCardSkeleton count={10} />
      ) : (
        <JobListings jobs={jobs} />
      )}
      
      <Pagination
        currentPage={page}
        totalPages={15}
        onPageChange={setPage}
      />
    </>
  )
}

Animation

Typical skeleton screens use a shimmer animation. You would add this to Loading.module.css:
@keyframes shimmer {
  0% {
    background-position: -468px 0;
  }
  100% {
    background-position: 468px 0;
  }
}

.skeleton {
  animation: shimmer 1.5s infinite;
  background: linear-gradient(
    to right,
    #f0f0f0 0%,
    #e0e0e0 20%,
    #f0f0f0 40%,
    #f0f0f0 100%
  );
  background-size: 800px 104px;
}

Best Practices

Match Your Layout

Set the count prop to match the number of jobs you typically display:
// If you show 12 jobs per page
<JobCardSkeleton count={12} />

// If you show a different amount based on screen size
const isMobile = useMediaQuery('(max-width: 768px)')
<JobCardSkeleton count={isMobile ? 6 : 12} />

Consistent Dimensions

Ensure skeleton cards have the same dimensions as real JobCard components for a smooth transition.

Don’t Overuse

Only show skeletons for initial loading. For subsequent loads (like pagination), consider:
function PaginatedJobs() {
  const [page, setPage] = useState(1)
  const { data, isLoading, isFetching } = useQuery(['jobs', page])
  
  if (isLoading) {
    // First load - show skeleton
    return <JobCardSkeleton count={10} />
  }
  
  return (
    <>
      <div style={{ opacity: isFetching ? 0.5 : 1 }}>
        <JobListings jobs={data} />
      </div>
      {/* Show dim overlay or spinner instead of full skeleton */}
    </>
  )
}

Accessibility

ARIA Attributes

Consider adding ARIA attributes for better accessibility:
export function JobCardSkeleton({ count = 3 }) {
  return (
    <div 
      className={styles.skeletonContainer}
      role="status"
      aria-label="Loading jobs"
    >
      {/* Skeleton cards */}
    </div>
  )
}

Screen Reader Announcement

<div 
  className={styles.skeletonContainer}
  role="status" 
  aria-live="polite"
  aria-busy="true"
>
  <span className="sr-only">Loading job listings...</span>
  {/* Skeleton cards */}
</div>

Source Code

Location: src/components/Loading/Loading.jsx:3

JobListings

Component that displays after loading

JobCard

The component this skeleton represents

Performance

Optimize Re-renders

The skeleton component is simple and performant, but you can optimize further:
import { memo } from 'react'

export const JobCardSkeleton = memo(function JobCardSkeleton({ count = 3 }) {
  // Component code
})

Lazy Loading

For very large lists, consider lazy loading:
import { JobCardSkeleton } from '@/components'
import { useInView } from 'react-intersection-observer'

function LazyJobList() {
  const { ref, inView } = useInView()
  
  return (
    <>
      <JobListings jobs={loadedJobs} />
      <div ref={ref}>
        {inView && <JobCardSkeleton count={3} />}
      </div>
    </>
  )
}

Build docs developers (and LLMs) love