DEV Community

Cover image for Block Kit Basics: Building Slack Message UI
SapotaCorp
SapotaCorp

Posted on • Originally published at sapotacorp.vn

Block Kit Basics: Building Slack Message UI

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"
}
Enter fullscreen mode Exit fullscreen mode

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"}
    ]}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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*"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"}
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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_text only. Put formatting in a section.
  • Duplicate action_id in one message. Slack only delivers one action when two buttons share an ID. Keep every action_id unique within a message.
  • initial_option that isn't in options. For selects, the initial value has to exactly match one entry or the view fails to render. Same energy as a date picker fed 24/06/2026 instead 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.

Top comments (0)