> ## Documentation Index
> Fetch the complete documentation index at: https://beta-docs.replo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Listicle

> Build a listicle page that walks readers through the reasons to buy.

export const Device = ({prompt, desktopImageSource, mobileImageSource, imageAlt = "Template preview", buttonCta = "Build in Replo"}) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [isMobile, setIsMobile] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const [dragStart, setDragStart] = useState({
    x: 0,
    y: 0
  });
  const [scrollStart, setScrollStart] = useState({
    x: 0,
    y: 0
  });
  const [velocity, setVelocity] = useState({
    x: 0,
    y: 0
  });
  const [lastPos, setLastPos] = useState({
    x: 0,
    y: 0
  });
  const [lastTime, setLastTime] = useState(0);
  const [scrollContainer, setScrollContainer] = useState(null);
  const API_BASE = "https://api.replo.app";
  const APP_SIGNUP_URL = "https://dashboard.replo.app/auth/signup";
  const showToggle = desktopImageSource && mobileImageSource;
  const currentImage = showToggle && isMobile ? mobileImageSource : desktopImageSource;
  const handleMouseDown = (event, container) => {
    setIsDragging(true);
    setDragStart({
      x: event.clientX,
      y: event.clientY
    });
    setScrollStart({
      x: container.scrollLeft,
      y: container.scrollTop
    });
    setLastPos({
      x: event.clientX,
      y: event.clientY
    });
    setLastTime(Date.now());
    setVelocity({
      x: 0,
      y: 0
    });
    setScrollContainer(container);
    event.preventDefault();
  };
  const handleMouseMove = (event, container) => {
    if (!isDragging) return;
    const now = Date.now();
    const elapsedMs = now - lastTime;
    if (elapsedMs > 0) {
      const deltaX = event.clientX - dragStart.x;
      const deltaY = event.clientY - dragStart.y;
      const velocityX = (event.clientX - lastPos.x) / elapsedMs;
      const velocityY = (event.clientY - lastPos.y) / elapsedMs;
      container.scrollLeft = scrollStart.x - deltaX;
      container.scrollTop = scrollStart.y - deltaY;
      setVelocity({
        x: velocityX,
        y: velocityY
      });
      setLastPos({
        x: event.clientX,
        y: event.clientY
      });
      setLastTime(now);
    }
    event.preventDefault();
  };
  const handleMouseUp = () => {
    setIsDragging(false);
    if (scrollContainer && (Math.abs(velocity.x) > 0.1 || Math.abs(velocity.y) > 0.1)) {
      startMomentumScroll();
    }
  };
  const startMomentumScroll = () => {
    let currentVelocity = {
      ...velocity
    };
    const friction = 0.95;
    const minVelocity = 0.1;
    const animate = () => {
      if (!scrollContainer) return;
      scrollContainer.scrollLeft -= currentVelocity.x * 16;
      scrollContainer.scrollTop -= currentVelocity.y * 16;
      currentVelocity.x *= friction;
      currentVelocity.y *= friction;
      if (Math.abs(currentVelocity.x) > minVelocity || Math.abs(currentVelocity.y) > minVelocity) {
        requestAnimationFrame(animate);
      }
    };
    requestAnimationFrame(animate);
  };
  useEffect(() => {
    const handleGlobalMouseUp = () => {
      setIsDragging(false);
    };
    window.addEventListener("mouseup", handleGlobalMouseUp);
    return () => {
      window.removeEventListener("mouseup", handleGlobalMouseUp);
    };
  }, []);
  const LoaderIcon = <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{
    animation: "spin 1s linear infinite"
  }}>
      <path d="M21 12a9 9 0 1 1-6.219-8.56" />
    </svg>;
  const ChevronIcon = <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="m9 18 6-6-6-6" />
    </svg>;
  async function postJSON(url, body, headers = {}, timeoutMs = 120000) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...headers
        },
        body: JSON.stringify(body ?? ({})),
        signal: controller.signal
      });
      const responseText = await response.text();
      if (!response.ok) throw new Error(`API Error: ${response.status}`);
      return responseText ? JSON.parse(responseText) : {};
    } finally {
      clearTimeout(timeoutId);
    }
  }
  async function handleTryPrompt(event) {
    event.preventDefault();
    event.stopPropagation();
    setIsLoading(true);
    setError(null);
    try {
      const body = {
        prompt,
        file: null
      };
      const {seed} = await postJSON(`${API_BASE}/api/v1/marketing/issue-marketing-site-jwt`, body);
      if (!seed) throw new Error("No seed returned from API");
      const url = new URL(APP_SIGNUP_URL);
      url.hash = `seed=${encodeURIComponent(seed)}`;
      window.open(url.toString(), "_blank");
      setIsLoading(false);
    } catch (caughtError) {
      console.error("Failed to generate prompt:", caughtError);
      const message = caughtError instanceof Error ? caughtError.message : null;
      setError(message ?? "Something went wrong. Please try again.");
      setIsLoading(false);
    }
  }
  return <div className="device-wrapper" style={{
    width: "100%",
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    position: "relative",
    paddingTop: showToggle ? "16px" : "0"
  }}>
      {}
      {showToggle && <div style={{
    display: "flex",
    gap: "0.5rem",
    alignItems: "center",
    padding: "0.25rem",
    backgroundColor: "#f3f4f6",
    borderRadius: "9999px",
    position: "absolute",
    top: "0",
    left: "50%",
    transform: "translateX(-50%)",
    zIndex: 20
  }}>
          <button onClick={() => setIsMobile(false)} style={{
    padding: "0.375rem 0.75rem",
    fontSize: "0.8125rem",
    fontWeight: "500",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: "pointer",
    backgroundColor: !isMobile ? "white" : "transparent",
    color: !isMobile ? "#111827" : "#6b7280",
    boxShadow: !isMobile ? "0 1px 3px 0 rgba(0, 0, 0, 0.1)" : "none",
    transition: "all 0.2s ease"
  }}>
            Desktop
          </button>
          <button onClick={() => setIsMobile(true)} style={{
    padding: "0.375rem 0.75rem",
    fontSize: "0.8125rem",
    fontWeight: "500",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: "pointer",
    backgroundColor: isMobile ? "white" : "transparent",
    color: isMobile ? "#111827" : "#6b7280",
    boxShadow: isMobile ? "0 1px 3px 0 rgba(0, 0, 0, 0.1)" : "none",
    transition: "all 0.2s ease"
  }}>
            Mobile
          </button>
        </div>}

      {}
      <div style={{
    position: "relative",
    display: "flex",
    justifyContent: "center",
    alignItems: "center",
    width: "100%"
  }} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
        {isMobile && mobileImageSource ? <div className="device device-iphone-14-pro device-mobile-responsive" style={{
    transform: "scale(0.7)",
    transformOrigin: "center center"
  }}>
            <div className="device-frame">
              <div className="device-screen" style={{
    overflow: "auto",
    overflowX: "hidden",
    cursor: isDragging ? "grabbing" : "grab",
    userSelect: "none"
  }} onMouseDown={event => {
    handleMouseDown(event, event.currentTarget);
  }} onMouseMove={event => {
    handleMouseMove(event, event.currentTarget);
  }}>
                <img src={mobileImageSource} alt={imageAlt} loading="lazy" style={{
    width: "100%",
    height: "auto",
    display: "block",
    pointerEvents: "none"
  }} draggable="false" />
              </div>
            </div>
            <div className="device-stripe"></div>
            <div className="device-header"></div>
            <div className="device-sensors"></div>
            <div className="device-btns"></div>
            <div className="device-power"></div>
            <div className="device-home"></div>
          </div> : desktopImageSource ? <div className="device device-macbook-pro device-desktop-responsive" style={{
    transform: "scale(0.85)",
    transformOrigin: "center center"
  }}>
            <div className="device-frame">
              <div className="device-screen" style={{
    overflow: "auto",
    overflowX: "hidden",
    cursor: isDragging ? "grabbing" : "grab",
    userSelect: "none"
  }} onMouseDown={event => {
    handleMouseDown(event, event.currentTarget);
  }} onMouseMove={event => {
    handleMouseMove(event, event.currentTarget);
  }}>
                <img src={desktopImageSource} alt={imageAlt} loading="lazy" style={{
    width: "100%",
    height: "auto",
    display: "block",
    pointerEvents: "none"
  }} draggable="false" />
              </div>
            </div>
            <div className="device-stripe"></div>
            <div className="device-header"></div>
            <div className="device-sensors"></div>
            <div className="device-btns"></div>
            <div className="device-power"></div>
            <div className="device-home"></div>
          </div> : null}

        {}
        {currentImage && <div style={{
    position: "absolute",
    top: "50%",
    left: "50%",
    transform: "translate(-50%, -50%)",
    zIndex: 10
  }}>
            <button type="button" onClick={handleTryPrompt} disabled={isLoading} style={{
    backgroundColor: "rgb(39, 74, 226)",
    color: "white",
    padding: "0.75rem 1.5rem",
    fontSize: "1rem",
    fontWeight: "600",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: isLoading ? "not-allowed" : "pointer",
    textAlign: "center",
    lineHeight: "1.5rem",
    whiteSpace: "nowrap",
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    WebkitFontSmoothing: "antialiased",
    boxSizing: "border-box",
    opacity: isLoading ? 0.7 : 1,
    transition: "background-color 0.2s ease"
  }} onMouseEnter={event => {
    if (!isLoading) {
      event.currentTarget.style.backgroundColor = "rgb(59, 94, 246)";
    }
  }} onMouseLeave={event => {
    if (!isLoading) {
      event.currentTarget.style.backgroundColor = "rgb(39, 74, 226)";
    }
  }}>
              <span style={{
    visibility: isLoading ? "hidden" : "visible",
    display: "inline-flex",
    alignItems: "center",
    gap: "0.5rem"
  }}>
                {buttonCta}
                {ChevronIcon}
              </span>
              {isLoading && <span style={{
    position: "absolute",
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center"
  }}>
                  {LoaderIcon}
                </span>}
            </button>
          </div>}
      </div>

      {}
      <style>
        {`
          @keyframes spin {
            from {
              transform: rotate(0deg);
            }
            to {
              transform: rotate(360deg);
            }
          }
          
          .device-screen {
            scrollbar-width: none; /* Firefox */
            -ms-overflow-style: none; /* IE and Edge */
          }
          
          .device-screen::-webkit-scrollbar {
            display: none; /* Chrome, Safari, Opera */
          }

          /* Desktop - compensate for scaling with negative margins */
          .device-mobile-responsive {
            margin-top: -80px;
            margin-bottom: -80px;
          }

          .device-desktop-responsive {
            margin-top: 16px;
            margin-bottom: 16px;
          }

          /* Mobile viewports - simplified with fixed height and clipping */
          @media (max-width: 768px) {
            .device-wrapper {
              overflow: hidden;
            }

            .device-mobile-responsive {
              transform: scale(0.5) !important;
              margin-top: -160px;
              margin-bottom: -160px;
            }

            .device-desktop-responsive {
              transform: scale(0.5) !important;
              margin-top: -60px;
              margin-bottom: -60px;
            }
          }
        `}
      </style>

      {}
      {error && <div style={{
    marginTop: "12px",
    padding: "12px 16px",
    backgroundColor: "#fee",
    border: "1px solid #fcc",
    borderRadius: "6px",
    color: "#c33",
    fontSize: "14px",
    textAlign: "center"
  }} role="alert">
          {error}
        </div>}
    </div>;
};

<Device prompt="can you rebuild this listicle exactly: https://theturmeric.co/pages/7-reasons-why" desktopImageSource="/images/use-cases/listicle/desktop.png" mobileImageSource="/images/use-cases/listicle/mobile.png" imageAlt="Listicle template" buttonCta="Build in Replo" />

## Step 1: Start Your Listicle with a Prompt

### Starter prompt

The more info and context you add here, the better the output will be.

```
Create a landing page listicle inspired by https://theturmeric.co/pages/7-reasons-why, titled [‘5 Reasons Why Our Vitamin Gummies Are Better Than Capsules.’]
Target [busy adults looking for easy wellness solutions]. Use a [friendly, trustworthy tone].
```

## Step 2: Customize Your Copy and Images

Don't feel locked into the AI's first draft. Use it as a starting point, but personalize the content to be on-brand and relevant to your business.

<AccordionGroup>
  <Accordion title="Update your listicle content">
    Replace the AI-generated content with your actual reasons, headlines, and supporting copy.

    ```
    Please replace the content with the following:

    Listicle title: [Catchy listicle title]

    Reason 1: [Headline or short statement]
    Supporting copy: [2-3 sentences explaining this point]

    Reason 2: [Headline or short statement]
    Supporting copy: [2-3 sentences explaining this point]

    Reason 3: [Headline or short statement]
    Supporting copy: [2-3 sentences explaining this point]
    ```
  </Accordion>

  <Accordion title="Add your images">
    Replace placeholder images with your actual product photography, lifestyle shots, or supporting visuals.

    ```
    Update the images:

    Reason 1 image: https://yourstore.com/images/reason1.png
    Reason 2 image: https://yourstore.com/images/reason2.png
    Reason 3 image: https://yourstore.com/images/reason3.png
    ```

    <Tip>
      Use high-quality images that visually support each reason. Product shots work well for feature comparisons, while lifestyle images work better for benefit-driven listicles.
    </Tip>
  </Accordion>
</AccordionGroup>

## Step 3: Personalize the Design

The output will incorporate your brand's font and color styles automatically. But if you have a specific design vision, you can customize it further.

<Tabs>
  <Tab title="From a URL">
    You can reference any URL, whether it's your own site or inspiration you found online.

    **How to:**

    ```
    Can you replicate the styles from [input URL] and apply it to my page?

    I like how it uses [dashed borders, bold headlines, card layouts, etc.]
    ```

    This works well when you want to match an existing page style or apply design patterns from successful examples.
  </Tab>

  <Tab title="From a Screenshot">
    Drop an image to show the exact visual direction you want.

    **How to:**

    ```
    Can you apply the styles from this image to my page?

    I like how it uses [dashed borders, bold headlines, color scheme, etc.]
    ```

    This is especially useful when you have a design comp or specific visual reference you want to match.
  </Tab>

  <Tab title="From a Prompt">
    Use natural language to describe the visual feel you want your page to have. Be specific about design elements and styles you want to include.

    **Example:**

    ```
    I want my page to [feel less flat and incorporate more visual design elements].

    Use [rounded corners, subtle shadows, and gradient backgrounds] to make the sections more distinct.

    Make the headlines [larger and bolder] to improve scannability.
    ```

    <Tip>
      The more specific you are about design elements, the better the output. Instead of "make it modern," say "use clean sans-serif fonts, generous white space, and subtle shadows."
    </Tip>
  </Tab>
</Tabs>

## Step 4: Set Up Your Links

Use [select mode](/features/edit-mode) to select buttons on the page. Once your button is selected, enter the following prompt:

```
This button should redirect to [Enter URL].
```

Or if you want all the buttons on this page to redirect to the same URL, enter the following prompt:

```
Update all the buttons on this page to redirect to [enter URL].
```

## Step 5: Publish

Once you're happy with the design and content, click **Publish** in the top-right corner of the Website Builder:

1. **Publish to your Replo domain**: every workspace gets a default domain (e.g. `yourshop.replosites.com`). This is great for quick testing or sharing preview links.
2. **Add a custom domain**: connect your own store domain to publish live pages directly under your brand's URL.

[*Read more about Publishing and Custom Domains*](/publishing)*.*

Once you've selected where to publish, click **Publish** again to make your listicle live.

## Listicle Examples
