instagram-tweet-carousel
Instagram Tweet-style Carousel Generator (X / Twitter Layout)
Generates fully self-contained, swipeable HTML carousels where every slide mimics an authentic X (Twitter) post — designed to be exported as individual 1080×1350px PNGs for Instagram.
Working Directory Rules
All file operations MUST use the current working directory (cwd) as the base. Never use hardcoded absolute paths like C:\Users\marco\.
- Detect cwd at the start of every session using
pwdoros.getcwd() - Save all generated files (HTML, PNGs) relative to cwd
- The
ASSETS/folder is always{cwd}/ASSETS/ - The
.claude/launch.jsonis always{cwd}/.claude/launch.json - The export output folder is always
{cwd}/slides/ - Never scan directories outside of cwd for project files or images
Step 0 — Ask Background Preference First
Before collecting any other details, always ask:
"Você prefere o fundo claro (branco, estilo X padrão) ou escuro (preto, estilo X dark mode)?"
This choice determines the entire color system and font colors. Do not proceed until the user answers.
Step 1: Collect Brand Details
After background preference is confirmed, ask for:
- Display name — shown in the post header (e.g., "Marco Lang")
- X handle — shown below the name (e.g., @marcolang)
- Verified badge — blue checkmark yes/no (default: yes)
- Profile photo — before asking the user anything about the photo, search in this order:
{cwd}/assets/— check the project folder first (case-insensitive:assets/orASSETS/)- Skill folder
assets/— if not found above, check the skill's own assets folder at~/.claude/skills/instagram-carousel-tweet/assets/
- Use the Glob tool or
lsto list files in each location - If an image file is found (JPG, PNG, WEBP) in either location, use it automatically and tell the user which file was picked — do not ask
- Only ask the user to provide a photo if no image is found in either location
- Content / topic — what the carousel is about
- Idioma dos slides — default: Português (BR) unless specified otherwise
- Number of slides — default: 7
If the user provides a website URL or brand assets, derive name and handle from those.
If the user says "make me a carousel about X" without brand details, ask before generating. Don't assume defaults.
Handling User-Provided Images
This section applies from the very first HTML generation — not only during export.
The profile photo will be located inside the ASSETS/ folder in the working directory. Always:
- Find the file in
ASSETS/(e.g.,ASSETS/foto.jpg,ASSETS/profile.png) - Check the actual file format with the
filecommand — extension may lie - Embed as base64
data:URI — never use relative paths
⚠️ Critical Rules
- NEVER use relative paths (
ASSETS/foto.jpg) — they break in every browser context except the exact folder the HTML lives in. - NEVER use
background: url(filepath)— leads to 1.5MB+ base64 inline strings that crash the browser parser. - ALWAYS embed as base64
data:URI — works in preview, export, and any environment. - ALWAYS generate the HTML via Python (
Path.write_text()) — shell heredocs interpolate$and backticks, corrupting base64 strings.
Step-by-step: embed the profile photo
# 1. Check the actual file format (extension may lie)
file ASSETS/profile.jpg
import base64
from pathlib import Path
# 2. Read and encode
img_path = Path("ASSETS/profile.jpg") # adjust filename
# Use "image/jpeg" if `file` command says JPEG, else "image/png"
mime = "image/jpeg" # or "image/png"
b64 = base64.b64encode(img_path.read_bytes()).decode()
avatar_uri = f"data:{mime};base64,{b64}"
# 3. Inject into HTML template as a Python variable — never via shell
html = f"""...<img src="{avatar_uri}" style="width:100%;height:100%;object-fit:cover;">..."""
Path("carousel.html").write_text(html, encoding="utf-8")
Common image mistakes to avoid
| Mistake | What goes wrong | Fix |
|---|---|---|
<img src="ASSETS/foto.jpg"> |
Broken image in browser | Always use base64 data: URI |
background: url('data:...') inline with 1.5MB base64 |
Browser parser crash | Use <img> tag with object-fit:cover |
| Generating HTML via shell heredoc | $ and backtick chars corrupt base64 |
Always use Python Path.write_text() |
Assuming .png extension = PNG |
Wrong MIME type breaks rendering | Run file command first |
Step 2: Color System
Two modes only — no gradients, no brand-derived palettes. The X aesthetic is deliberately minimal.
Light Mode (fundo claro)
SLIDE_BG = #FFFFFF // Slide background
TEXT_PRIMARY = #0F1419 // Display name, tweet body text
TEXT_MUTED = #536471 // Handle, timestamps, muted labels
DIVIDER = #EFF3F4 // Separator lines, borders
ICON_COLOR = #536471 // Engagement icons
X_BLUE = #1D9BF0 // Verified badge, links, accent
SLIDE_NUM_BG = rgba(0,0,0,0.06) // Slide counter pill background
SLIDE_NUM_TEXT = #536471 // Slide counter text
Dark Mode (fundo escuro)
SLIDE_BG = #000000 // Slide background
TEXT_PRIMARY = #E7E9EA // Display name, tweet body text
TEXT_MUTED = #71767B // Handle, timestamps, muted labels
DIVIDER = #2F3336 // Separator lines, borders
ICON_COLOR = #71767B // Engagement icons
X_BLUE = #1D9BF0 // Verified badge, links, accent
SLIDE_NUM_BG = rgba(255,255,255,0.08) // Slide counter pill background
SLIDE_NUM_TEXT = #71767B // Slide counter text
Step 3: Typography
X uses a system font stack. Use Inter from Google Fonts as the closest equivalent.
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
Font size scale:
- Display name: 16px, weight 700, color
TEXT_PRIMARY - Handle: 15px, weight 400, color
TEXT_MUTED - Tweet body: 20px, weight 400, line-height 1.55, color
TEXT_PRIMARY(Large text makes each slide readable at a glance — key for Instagram carousels) - Engagement counts: 13px, weight 400, color
TEXT_MUTED - Slide counter: 13px, weight 500
Apply via CSS class .x-font using font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif.
Slide Structure (X Post Layout)
Every slide is an exact replica of an X post. Structure from top to bottom:
┌─────────────────────────────────────┐ ← SLIDE_BG background
│ [Avatar] Display Name ✓ │ ← Profile header (always on every slide)
│ @handle │
├─────────────────────────────────────┤ ← thin divider (DIVIDER color)
│ │
│ Tweet body text goes here. │ ← Content area (large, readable)
│ It can span multiple lines │
│ and fill most of the slide. │
│ │
├─────────────────────────────────────┤ ← thin divider
│ 💬 Reply 🔁 Repost ♡ Like 📊 │ ← X engagement bar (decorative)
│ [1/7] │ ← Slide counter (right side)
└─────────────────────────────────────┘
Profile Header
<div class="x-header" style="display:flex;align-items:center;gap:12px;padding:20px 20px 14px;">
<!-- Avatar: circular, 48px, profile photo -->
<div style="width:48px;height:48px;border-radius:50%;overflow:hidden;flex-shrink:0;">
<img src="{avatar_uri}" style="width:100%;height:100%;object-fit:cover;">
</div>
<!-- Name + handle -->
<div style="display:flex;flex-direction:column;gap:1px;">
<div style="display:flex;align-items:center;gap:4px;">
<span class="x-font" style="font-size:16px;font-weight:700;color:{TEXT_PRIMARY};">{Display Name}</span>
<!-- Verified badge (if enabled) -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="{X_BLUE}">
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z"/>
</svg>
</div>
<span class="x-font" style="font-size:15px;font-weight:400;color:{TEXT_MUTED};">@{handle}</span>
</div>
</div>
<!-- Thin divider below header -->
<div style="height:1px;background:{DIVIDER};margin:0 20px;"></div>
Content Area
<div class="x-content" style="flex:1;padding:18px 20px 16px;display:flex;align-items:flex-start;">
<p class="x-font" style="font-size:20px;font-weight:400;line-height:1.55;color:{TEXT_PRIMARY};margin:0;">
{Tweet text for this slide}
</p>
</div>
Content rules:
- Text should feel like a natural tweet — direct, punchy, no corporate speak
- Each slide = one thought / one point
- First slide is the hook (question, bold statement, number)
- Last slide is the CTA (follow, save, share)
- Do NOT repeat the same intro phrase on every slide
Engagement Bar + Slide Counter
<!-- Thin divider above engagement bar -->
<div style="height:1px;background:{DIVIDER};margin:0 20px;"></div>
<!-- Engagement bar -->
<div class="x-engagement" style="display:flex;align-items:center;padding:12px 20px 20px;gap:0;">
<!-- Reply -->
<div style="display:flex;align-items:center;gap:6px;flex:1;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{ICON_COLOR}" stroke-width="1.8">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="x-font" style="font-size:13px;color:{TEXT_MUTED};">{reply_count}</span>
</div>
<!-- Repost -->
<div style="display:flex;align-items:center;gap:6px;flex:1;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{ICON_COLOR}" stroke-width="1.8">
<path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
<span class="x-font" style="font-size:13px;color:{TEXT_MUTED};">{repost_count}</span>
</div>
<!-- Like -->
<div style="display:flex;align-items:center;gap:6px;flex:1;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{ICON_COLOR}" stroke-width="1.8">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<span class="x-font" style="font-size:13px;color:{TEXT_MUTED};">{like_count}</span>
</div>
<!-- Views -->
<div style="display:flex;align-items:center;gap:6px;flex:1;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{ICON_COLOR}" stroke-width="1.8">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
<span class="x-font" style="font-size:13px;color:{TEXT_MUTED};">{view_count}</span>
</div>
<!-- Slide counter pill (right-aligned) -->
<div style="margin-left:auto;padding:4px 10px;background:{SLIDE_NUM_BG};border-radius:20px;">
<span class="x-font" style="font-size:13px;font-weight:500;color:{SLIDE_NUM_TEXT};">{i+1}/{total}</span>
</div>
</div>
Engagement numbers: Use realistic-looking but fictional numbers. Example: 847 replies, 2.3K reposts, 18.4K likes, 412K views. Vary per slide.
Slide Sequences
Standard Thread (7 slides — default)
| # | Type | Content |
|---|---|---|
| 1 | Hook | Bold statement, question, or surprising number that stops the scroll |
| 2 | Problem | Pain point or context — why this matters |
| 3 | Insight 1 | First key point |
| 4 | Insight 2 | Second key point |
| 5 | Insight 3 | Third key point |
| 6 | Deep dive | Most detailed / surprising revelation |
| 7 | CTA | Follow, save, share — clear action |
Listicle Thread (5–10 slides)
| # | Content |
|---|---|
| 1 | Hook — "X coisas que..." |
| 2–N | One item per slide, numbered |
| Last | CTA |
Tutorial Thread (6 slides)
| # | Content |
|---|---|
| 1 | Hook — what will be taught |
| 2 | Why this matters |
| 3–5 | Steps 1, 2, 3 |
| 6 | CTA |
All sequences:
- Every slide has the exact same X post header (avatar + name + handle)
- Same background throughout (no alternating dark/light)
- Hook on slide 1 is mandatory
- CTA on last slide is mandatory
Slide Architecture
Format
- Aspect ratio: 4:5 (Instagram carousel standard)
- Canvas: 420×525px in HTML preview (exported at 1080×1350px)
- Every slide: same background color (SLIDE_BG), no gradients, no alternating
No Progress Bar
X posts don't have progress bars. Replace with the slide counter pill inside the engagement bar (see above). This is the only slide position indicator.
No Swipe Arrow
X posts don't have decorative arrows. Omit entirely.
Preview Wrapper (X Thread Frame)
When displaying in chat, wrap in an X-style post frame:
┌─────────────────────────────────────┐
│ X [Search...] 🔔 👤 │ ← X app top bar (simplified)
├─────────────────────────────────────┤
│ ← Back Post │ ← Post header bar
├─────────────────────────────────────┤
│ [Swipeable carousel viewport] │ ← 420×525px
├─────────────────────────────────────┤
│ ● ● ○ ○ ○ ○ ○ │ ← Dot indicators
└─────────────────────────────────────┘
- Frame width: exactly 420px — never change this, export depends on it
- Carousel viewport: 420×525px
- Top bar: dark or light matching the slide background; X logo (bird/X icon), search bar, bell, profile icons
- Dots: small dot indicators below the viewport, styled to match X's minimalism
- No caption area — X posts don't have Instagram-style captions
- Include pointer-based swipe/drag interaction for preview
Class names to use:
.x-frame— outer wrapper (420px wide).x-topbar— app top bar.carousel-viewport— 420×525px clip area.carousel-track— flex row of all slides.x-dots— dot indicators- Each slide:
.x-slide
Review Flow
Always follow this flow. Never skip to export without approval.
- Generate the HTML preview first — never jump directly to export
- Show the preview and ask: "Quais slides precisam de ajuste antes de exportar?"
- Fix only the mentioned slides — never regenerate the entire carousel unless the direction fundamentally changes
- Only proceed to export when the user explicitly confirms approval (e.g., "pode exportar", "aprovado", "ok")
Exporting Slides as Instagram-Ready PNGs
After the user approves the carousel preview, export each slide as an individual 1080×1350px PNG.
Critical Export Rules
- Use Python for HTML generation — never use shell scripts with variable interpolation. Always use
Path.write_text()oropen().write(). - Embed images as base64 — profile photo must be base64-encoded as
data:image/jpeg;base64,...URI. Check actual file format with thefilecommand. - Keep the 420px layout width — use Playwright's
device_scale_factorto scale up to 1080px output WITHOUT changing the layout viewport.
Install Playwright (only if needed)
python3 -c "import playwright" 2>/dev/null || pip3 install playwright
python3 -c "from playwright.sync_api import sync_playwright; sync_playwright().__enter__().chromium" 2>/dev/null || python3 -m playwright install chromium
Export Script
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright
INPUT_HTML = Path("/path/to/carousel.html")
OUTPUT_DIR = Path("/path/to/output/slides")
OUTPUT_DIR.mkdir(exist_ok=True)
TOTAL_SLIDES = 7 # Update to match your carousel
VIEW_W = 420
VIEW_H = 525
SCALE = 1080 / 420 # = 2.5714...
async def export_slides():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(
viewport={"width": VIEW_W, "height": VIEW_H},
device_scale_factor=SCALE,
)
html_content = INPUT_HTML.read_text(encoding="utf-8")
await page.set_content(html_content, wait_until="networkidle")
await page.wait_for_timeout(3000) # Wait for Google Fonts to load
# Hide X frame chrome, show only the slide viewport
await page.evaluate("""() => {
document.querySelectorAll('.x-topbar,.x-dots')
.forEach(el => el.style.display='none');
const frame = document.querySelector('.x-frame');
frame.style.cssText = 'width:420px;height:525px;max-width:none;border-radius:0;box-shadow:none;overflow:hidden;margin:0;';
const viewport = document.querySelector('.carousel-viewport');
viewport.style.cssText = 'width:420px;height:525px;aspect-ratio:unset;overflow:hidden;cursor:default;';
document.body.style.cssText = 'padding:0;margin:0;display:block;overflow:hidden;';
}""")
await page.wait_for_timeout(500)
for i in range(TOTAL_SLIDES):
await page.evaluate("""(idx) => {
const track = document.querySelector('.carousel-track');
track.style.transition = 'none';
track.style.transform = 'translateX(' + (-idx * 420) + 'px)';
}""", i)
await page.wait_for_timeout(400)
await page.screenshot(
path=str(OUTPUT_DIR / f"slide_{i+1}.png"),
clip={"x": 0, "y": 0, "width": VIEW_W, "height": VIEW_H}
)
print(f"Exported slide {i+1}/{TOTAL_SLIDES}")
await browser.close()
asyncio.run(export_slides())
Common Export Mistakes to Avoid
| Mistake | What goes wrong | Fix |
|---|---|---|
| Setting viewport to 1080×1350 | Layout reflows — fonts tiny, spacing breaks | Keep viewport at 420×525, use device_scale_factor |
| Using shell scripts to generate HTML | $ signs and backticks get interpolated |
Always use Python for HTML generation |
| Not waiting for fonts | Headings render in fallback system fonts | wait_for_timeout(3000) after page load |
| Not hiding X frame chrome | Export includes topbar and dots | Hide .x-topbar,.x-dots |
Changing .x-frame width |
Entire layout shifts | Always keep at exactly 420px |
| Using relative path for avatar | Broken image in every context | Always embed as base64 data: URI |
Design Principles
- Authenticity first — every slide must look like a real X post, not a designed graphic
- Same background throughout — no alternating colors; X threads are visually consistent
- Large body text — 20px for tweet content ensures readability after Instagram export
- Profile header on every slide — users scrolling Instagram must always see who posted
- Fictional but realistic engagement numbers — adds social proof and authenticity
- Hook-first copy — Slide 1 is the scroll-stopper; every subsequent slide must earn the swipe
- One thought per slide — each tweet is a standalone idea, not a continuation mid-sentence
- CTA is always last — follow, save, share — one clear action
- Iterate fast — show preview, fix specific slides, don't rebuild from scratch
- Dark or light — never mixed — respect the user's mode choice across all slides