ExpoStartup

SkeletonLoader

A customizable loading placeholder component with various variants for different UI patterns.

SkeletonLoader

The SkeletonLoader component provides animated placeholders that can be used during content loading states. It supports different variants to match common UI patterns like lists, grids, articles, and chat interfaces.

Features

  • Multiple Variants: Predefined layouts for list, grid, article, and chat interfaces
  • Animated Opacity: Smooth opacity animation creates a pulsing effect
  • Customizable Count: Render multiple skeleton items for list and grid variants
  • Theming Support: Automatically adapts to light/dark mode
  • Styling Control: Accepts additional Tailwind classes

Import

import SkeletonLoader from '@/components/SkeletonLoader';

Props

PropTypeDefaultDescription
variant'list' | 'grid' | 'article' | 'chat'RequiredType of skeleton layout to render
countnumber1Number of skeleton items to render (for list and grid variants)
classNamestring''Additional Tailwind classes

Usage Examples

List Skeleton

<SkeletonLoader variant="list" count={5} />

Grid Skeleton

<SkeletonLoader variant="grid" count={4} />

Article Skeleton

<SkeletonLoader variant="article" />

Chat Skeleton

<SkeletonLoader variant="chat" />

Integration Examples

Product List Loading State

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    // Fetch products
    fetchProducts()
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching products:', error);
        setLoading(false);
      });
  }, []);
 
  if (loading) {
    return <SkeletonLoader variant="list" count={6} />;
  }
 
  return (
    <FlatList
      data={products}
      renderItem={({ item }) => <ProductListItem product={item} />}
      keyExtractor={item => item.id.toString()}
    />
  );
}
function ImageGallery() {
  const [images, setImages] = useState([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    // Fetch images
    fetchGalleryImages()
      .then(data => {
        setImages(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching images:', error);
        setLoading(false);
      });
  }, []);
 
  if (loading) {
    return <SkeletonLoader variant="grid" count={12} />;
  }
 
  return (
    <Grid columns={3} spacing={8}>
      {images.map(image => (
        <Image
          key={image.id}
          source={{ uri: image.url }}
          className="aspect-square rounded-lg"
        />
      ))}
    </Grid>
  );
}

Article Detail Loading State

function ArticleDetail({ articleId }) {
  const [article, setArticle] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    // Fetch article details
    fetchArticleById(articleId)
      .then(data => {
        setArticle(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching article:', error);
        setLoading(false);
      });
  }, [articleId]);
 
  if (loading) {
    return <SkeletonLoader variant="article" />;
  }
 
  return (
    <ScrollView>
      <Image 
        source={{ uri: article.coverImage }} 
        className="w-full aspect-square"
      />
      <View className="p-4">
        <Text className="text-2xl font-bold mb-2">{article.title}</Text>
        <Text className="text-gray-500 mb-4">{article.publishDate}</Text>
        <Text>{article.content}</Text>
      </View>
    </ScrollView>
  );
}

Chat Screen Loading State

function ChatScreen({ conversationId }) {
  const [messages, setMessages] = useState([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    // Fetch chat messages
    fetchChatMessages(conversationId)
      .then(data => {
        setMessages(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching messages:', error);
        setLoading(false);
      });
  }, [conversationId]);
 
  if (loading) {
    return (
      <View className="flex-1">
        <Header title="Chat" showBackButton={true} />
        <SkeletonLoader variant="chat" />
      </View>
    );
  }
 
  return (
    <View className="flex-1">
      <Header title="Chat" showBackButton={true} />
      <FlatList
        data={messages}
        renderItem={({ item }) => <ChatMessage message={item} />}
        keyExtractor={item => item.id.toString()}
      />
    </View>
  );
}

Variant Showcase

List Variant

The list variant creates a series of rows with a thumbnail on the left and text placeholders on the right, perfect for content lists:

<SkeletonLoader variant="list" count={3} />

This creates a skeleton that resembles:

  • A square thumbnail (16x16)
  • Two lines of text beside it (title and subtitle)
  • Repeated for the number specified in count

Grid Variant

The grid variant creates a grid of cards with image placeholders and text below, ideal for galleries or product grids:

<SkeletonLoader variant="grid" count={4} />

This creates a skeleton that resembles:

  • Square image placeholders arranged in a grid
  • Two lines of text below each image
  • Items fill 50% width (2 columns)
  • Repeated for the number specified in count

Article Variant

The article variant creates a single full-width image placeholder with text blocks below, perfect for article or product detail views:

<SkeletonLoader variant="article" />

This creates a skeleton that resembles:

  • A large image placeholder at the top (full width)
  • A title and subtitle placeholder
  • Multiple paragraph text placeholders below

Chat Variant

The chat variant creates message bubbles alternating between left and right alignment, ideal for messaging interfaces:

<SkeletonLoader variant="chat" />

This creates a skeleton that resembles:

  • A message bubble aligned to the left
  • A message bubble aligned to the right
  • Another message bubble aligned to the left

Best Practices

Match Skeleton to Content

Choose a skeleton variant that closely resembles your actual content:

// For a contact list
<SkeletonLoader variant="list" count={10} />
 
// For a photo gallery
<SkeletonLoader variant="grid" count={12} />
 
// For a news article
<SkeletonLoader variant="article" />
 
// For a messaging app
<SkeletonLoader variant="chat" />

Control Loading States

Make sure to handle loading states and errors properly:

function DataDisplay() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    fetchData()
      .then(result => {
        setData(result);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
 
  if (loading) {
    return <SkeletonLoader variant="list" count={5} />;
  }
 
  if (error) {
    return <ErrorMessage message={error.message} />;
  }
 
  return <DataList data={data} />;
}

Combine with Pull-to-Refresh

Use the skeleton loader with pull-to-refresh functionality:

function RefreshableList() {
  const [data, setData] = useState([]);
  const [refreshing, setRefreshing] = useState(false);
  const [loading, setLoading] = useState(true);
 
  const fetchData = useCallback(() => {
    setLoading(true);
    api.getData()
      .then(result => {
        setData(result);
        setLoading(false);
        setRefreshing(false);
      })
      .catch(error => {
        console.error(error);
        setLoading(false);
        setRefreshing(false);
      });
  }, []);
 
  useEffect(() => {
    fetchData();
  }, [fetchData]);
 
  const handleRefresh = () => {
    setRefreshing(true);
    fetchData();
  };
 
  if (loading && !refreshing) {
    return <SkeletonLoader variant="list" count={7} />;
  }
 
  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      refreshing={refreshing}
      onRefresh={handleRefresh}
    />
  );
}

Implementation Details

The SkeletonLoader component uses React Native's Animated API to create a pulsing effect by interpolating opacity values. It renders different placeholder layouts based on the selected variant.

Each variant uses a combination of Animated.View components with specific dimensions and styles to mimic the appearance of actual content. The component adapts to the current theme using the useThemeColors hook.

For list and grid variants, the component generates multiple placeholder items based on the count prop. The article and chat variants ignore this prop as they represent single-view layouts.

Notes

  • The skeleton animation automatically starts when the component mounts and stops when it unmounts
  • For list and grid variants, each skeleton item has its own animation, creating a unified pulsing effect
  • The component's background color automatically adapts to the current theme
  • The size of grid items is fixed at 50% width (2 columns), but you can customize this with your own styling