A plain text Slack message can carry exactly one thing: a string. When an e-commerce operations team we worked with first wired up their order-issue bot, every alert looked like this:
{
"channel": "C0XXXXX",
"text": "Order ORDER-123 has an issue. Customer: Nguyen Van A. Open channel: #order-issue-123"
}
One long line, no hierarchy, nothing to click. The on-call engineer had to copy the order ID out by hand and go dig through the OMS. Slack Block Kit is the answer to that problem. It's Slack's UI framework for composing structured messages and modals out of typed JSON objects instead of raw text. This post walks the core block types, the interactive elements that live inside them, and the rendering gotchas that catch every team on their first build.
Why blocks instead of text
A Block Kit message replaces the flat string with a blocks array. Each entry is a JSON object of a specific type, and Slack renders each one into a discrete UI chunk: a header, a two-column field grid, a divider, a row of buttons. Here's the same order-issue alert rebuilt:
{
"channel": "C0XXXXX",
"text": "New issue: ORDER-123",
"blocks": [
{"type": "header", "text": {"type": "plain_text", "text": "New issue: ORDER-123"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": "*Customer:*\nNguyen Van A"},
{"type": "mrkdwn", "text": "*Order value:*\n2,500,000 VND"}
]},
{"type": "divider"},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Resolve"},
"action_id": "resolve_issue", "value": "ORDER-123"}
]}
]
}
Notice the top-level text field is still there even though we have blocks. That is not optional and not decoration. Slack uses it as the fallback for mobile push notifications, screen readers, and search indexing. The blocks themselves never appear in a push notification, so if you skip text, the engineer's phone buzzes with an empty alert. Set it to a short summary every single time.
The five core block types
You can build most messages from five blocks.
Section is the workhorse. It holds text, a two-column fields array, or text plus a single right-aligned accessory element. The text object takes either plain_text or mrkdwn, and mrkdwn supports *bold*, _italic_, inline code, links, and mentions like <@U0OPS001>.
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Status:*\nIn progress"},
{"type": "mrkdwn", "text": "*SLA:*\n45 min"},
{"type": "mrkdwn", "text": "*Assignee:*\n<@U0OPS001>"},
{"type": "mrkdwn", "text": "*Priority:*\nP1"}
]
}
The fields array maxes out at 10 entries and renders left-to-right in two columns, which is ideal for metadata. The accessory slot holds exactly one element, so use it when you want a single button or image next to a line of text rather than a full actions row.
Divider is a horizontal rule with no content: {"type": "divider"}. Use it to separate logical sections, like primary info from metadata from actions. Don't stack five of them; two or three per message keeps things readable.
Header is the big bold title at the top. Its text must be plain_text and caps at 150 characters. One header per message is the rule. If you drop *bold* markup in here expecting it to render, it won't; you'll get the literal asterisks.
Context is the small-font footer line for secondary info. Its elements array holds up to 10 images or text fragments rendered inline, which makes it the natural home for "posted by", timestamps, and tags.
{
"type": "context",
"elements": [
{"type": "image", "image_url": "https://cdn.example.com/avatar.jpg", "alt_text": "avatar"},
{"type": "mrkdwn", "text": "Posted by <@U0OPS001> at 14:32 | Priority *P1*"}
]
}
Actions is the container for interactive elements laid out in a row. It takes up to 25 elements, though in practice 3 to 4 is the visual sweet spot. Buttons get a style of primary (green), danger (red), or default. Reserve primary for the main action and danger for anything destructive.
{
"type": "actions",
"elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Resolve"},
"action_id": "resolve_issue", "value": "ORDER-123", "style": "primary"},
{"type": "button", "text": {"type": "plain_text", "text": "Escalate"},
"action_id": "escalate_issue", "value": "ORDER-123", "style": "danger"},
{"type": "button", "text": {"type": "plain_text", "text": "View OMS"},
"action_id": "view_oms", "value": "ORDER-123",
"url": "https://oms.example.com/order/ORDER-123"}
]
}
Compose those five together and you get a real card: a header, a metadata grid, a description section, a divider, a button row, and a context footer. That's the entire structure of the issue card the ops team now pins in every incident channel, and it's the same shape a large engineering org uses for ArgoCD deploy approvals, just with Approve and Cancel buttons carrying a deploy-id in their value.
The elements inside blocks
A block is layout; an element is the interactive thing inside it. An element can't stand alone, it has to sit in an actions block, a section accessory, or an input block. Four matter most.
Button triggers an action and sends your app a block_actions payload. The fields you'll reach for: action_id (how you route the handler), value (a string payload, usually a record ID), style, an optional url that opens a tab while still firing the payload, and confirm, which pops a dialog before the action runs. Add confirm to anything destructive or expensive. A retail bank's marketing approval button uses it because approving a campaign budget can't be undone:
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve Marketing"},
"action_id": "approve_marketing",
"value": "campaign_C-2026-0042",
"style": "primary",
"confirm": {
"title": {"type": "plain_text", "text": "Confirm approval"},
"text": {"type": "mrkdwn", "text": "Approve this campaign? This cannot be undone."},
"confirm": {"type": "plain_text", "text": "Approve"},
"deny": {"type": "plain_text", "text": "Cancel"}
}
}
Image comes in two shapes. As its own block it renders full-width with an optional title; as an element inside context or an accessory it renders small and inline. Either way alt_text is required for accessibility, and image_url must be HTTPS and publicly hot-linkable. You can also reference an uploaded Slack file with slack_file: {"id": "F0XXXXX"} to dodge hosting entirely.
Date picker lets the user pick a calendar day and returns ISO YYYY-MM-DD. The key fields are action_id, an optional placeholder, and initial_date (which must also be YYYY-MM-DD). There are sibling timepicker and datetimepicker elements when you need time too.
{
"type": "datepicker",
"action_id": "start_date",
"placeholder": {"type": "plain_text", "text": "Pick a start date"},
"initial_date": "2026-06-24"
}
Select menu has five variants that differ only in where the options come from. Pick by data source:
| Situation | Element |
|---|---|
| Fixed list like P1/P2/P3 | static_select |
| Pick several from a fixed list | multi_static_select |
| Search a large DB of records | external_select |
| A user in the workspace | users_select |
| A channel | channels_select |
| Anything including DMs | conversations_select |
The static select inlines its options (max 100). The external select sends a query to your app as the user types and you return matching options live, which is how a 10,000-row campaign table stays usable. The typed selects (users_select, channels_select, conversations_select) let Slack populate the list for you, so you get IDs back without maintaining anything.
{
"type": "external_select",
"action_id": "campaign_search",
"placeholder": {"type": "plain_text", "text": "Search campaign"},
"min_query_length": 2
}
The gotchas that waste an afternoon
Most "why does my message look broken" tickets trace back to a handful of recurring mistakes:
- Missing the top-level
text. Blocks render fine in-channel but the push notification is blank. Always set a summary string. - Markdown link syntax. Slack mrkdwn uses
<https://example.com|Label>, not[Label](https://example.com). The bracket form renders literally, and a bare<https://example.com>shows the raw URL. - Bold in a header. Headers are
plain_textonly. Put formatting in a section. - Duplicate
action_idin one message. Slack only delivers one action when two buttons share an ID. Keep everyaction_idunique within a message. initial_optionthat isn't inoptions. For selects, the initial value has to exactly match one entry or the view fails to render. Same energy as a date picker fed24/06/2026instead of ISO.- Image URLs that block hot-linking. If your server does referrer checks, Slack's fetch gets blocked and the image renders broken. Serve from a permissive CDN or upload to Slack.
- Too many buttons. Six buttons in one actions row wrap badly on mobile. Cap at 3 to 4 and split into a second actions block if you need more.
Keep the hard limits in view too: 50 blocks per message, 100 per modal, 10 fields per section. When your data outgrows that, split into a thread or push it into a modal.
Takeaway
Block Kit is small once you see the pattern. Five blocks (section, divider, header, context, actions) cover layout, a handful of elements (button, image, date picker, select) cover interaction, and almost every gotcha is a constraint the renderer enforces silently: keep the fallback text, use Slack's link syntax, respect the type each field demands, and serve images from somewhere that lets Slack fetch them. Get those right and your messages stop looking like debug output and start looking like a product.
If you're building Slack apps, internal bots, or approval workflows and want them to feel native instead of bolted on, we do this for a living. Tell us what you're trying to ship at /contact, or see the rest of our Slack and platform engineering work at /service.








