Using the Kanban Board Component in UI Builder Kunal Khatri documented a step-by-step guide for building a Kanban board for incidents in ServiceNow's UI Builder (Next Experience), using the Kanban Board component from the sn-vtb library. The board groups incidents by priority, with lanes and cards driven by data resources and client scripts, including a GraphQL mutation for updating records on card moves. The configuration includes Transform and Composite data resources, client state parameters, and three client scripts to reproduce the board in any instance. Disclaimer:This article was written by an LLM. All the underlying content — configuration steps, scripts, and reference material — was carefully collected and verified by Kunal Khatri. This article is a companion write-up to our video walkthrough on building a Kanban board for Incidents in UI Builder Next Experience . It documents, end to end, the configuration steps we performed on screen and includes the exact client scripts we used, so you can reproduce the same board - lanes, cards, styling, lane footers, and the "Add Card" button - in your own instance. It is meant as an addendum to two excellent ServiceNow Community articles on the same component, and two "You & I Builder Live" sessions that cover the component interactively. Links to all four are in Further Reading further-reading at the end. The board in this article is bound to the incident table and groups incidents into lanes by Priority . Everything is driven by three UI Builder building blocks: | Element | Type | Purpose | |---|---|---| get priority values 1 | Transform data resource | Fetches the Priority choice list for incident , used to generate one lane per priority value | look up multiple records 1 | Composite "Look up multiple records" data resource | Fetches active incidents active=true , up to 1000 rows with the fields needed for the cards | update card | GraphQL data resource explicit/manual execution | Writes impact / urgency / work notes back to the incident when a card is dragged to a new lane | lanesData | Client State Parameter JSON | Backing array for the board's lanes property | cardsData | Client State Parameter JSON | Backing array for the board's cards property | newIncidentSysID | Client State Parameter string | Feeds the sys id of a newly created incident into a record-creation modal | kanban board 1 | Kanban Board sn-vtb macroponent | The board itself, with lanes and cards bound to the two state parameters above | Two client scripts run automatically when their data resource fetches succeed SetLanes , SetCards , and one runs in response to the board's CARD MOVED event Move cards . All three are reproduced in full below, at the step where they're relevant. - Open the page in UI Builder and drag the Kanban Board component from the component picker onto the canvas. - Before you can bind any data to it, create two Client State Parameters of type json : lanesData - initial value null cardsData - initial value null - Select the Kanban Board component and bind its properties: lanes → State Binding → lanesData cards → State Binding → cardsData At this point the board renders but is empty - nothing has populated lanesData or cardsData yet. That's what the next two steps do. The Kanban Board component ships as part of the sn-vtb Visual Task Board component library - make sure it's installed/enabled on your instance before it shows up in the component picker, as covered in the Part 1 reference article . Lanes come from data, not from manual configuration in the UI Builder canvas. We added a Transform data resource, get priority values 1 , that returns the Priority choice list for the incident table an array of { sys id, label, value } objects . A Transform data resource is backed by a server-side script the "transform data broker" , which runs on the server and returns whatever the client-side data resource then exposes as .output . Ours queries sys choice for the task table's priority element, ordered by value: js function transform { let choice list = ; var gr = new GlideRecord "sys choice" ; gr.addEncodedQuery "language=en^name=task^elementSTARTSWITHpriority" ; gr.orderBy "value" ; gr.query ; while gr.next { choice list.push { "sys id":gr.getUniqueValue , "label":gr.getValue "label" , "value":gr.getValue "value" } ; } return choice list; } A couple of notes on this query: - We query sys choice on name=task not name=incident because priority is defined on the base task table and inherited by incident - querying task picks up the choice list regardless of which task-derived table you point the board at. elementSTARTSWITHpriority matches the priority element; using STARTSWITH rather than an exact match is a defensive habit here, though an exact element=priority would work identically for this field. orderBy "value" gives us the choices in priority order 1–Critical through 5–Planning , which becomes the left-to-right lane order on the board since SetLanes below just maps the array in place. This script only produces the raw choice list - it doesn't know anything about lanes, styling, or the board. That transformation happens client-side, in the script mapped to the data resource's DATA FETCH SUCCEEDED event. We mapped a client script to that event. This script SetLanes turns each priority choice into a lane object and writes the result to lanesData : / @param {params} params @param {api} params.api @param {any} params.event @param {any} params.imports @param {ApiHelpers} params.helpers / function handler {api, event, helpers, imports} { var data = api.data.get priority values 1.output; var return value = data.map choice= { return { "id": "lane "+choice.value, "title": choice.label, "name": choice.label, " style ": { "sn-lane": { "width": "285px", "padding": "3px", "overflow-y": "auto", "height": "calc 100% - 58px " }, "lane-footer-container": { "position": "sticky", "bottom": "30px" } }, "options": { "board type": "FREEFORM", "is2DBoard": false, "lane filter": "", "lane count": 3, "total lane count": 3, "lane field": " KANBAN ", "swimlane field": "", "table": "incident", "highest lane order": 2, "can add task": true, "add card footer button name": "New Incident" } } } ; api.setState "lanesData",return value ; } A couple of things worth calling out: - every lane's id: "lane " + choice.value id is derived from the priority value e.g. lane 1 , lane 2 , … . We use this exact prefix again in the card script, so cards land in the correct lane. This lane id contract between the two scripts is the single most important detail to get right.and style are set per lane here - see Steps 4–6 below for what each of those keys does. options Cards are populated the same way: a data resource plus a client script on DATA FETCH SUCCEEDED . The data resource, look up multiple records 1 , is a Composite "Look up multiple records" resource querying incident with: encodedQuery : active=true returnFields : active,short description,description,priority,number limit : 1000 , sortType : asc The mapped client script SetCards maps each incident to a card object and writes the array to cardsData : / @param {params} params @param {api} params.api @param {any} params.event @param {any} params.imports @param {ApiHelpers} params.helpers / function handler { api, event, helpers, imports } { var incidents = api.data.look up multiple records 1.results; var return value = incidents.map incident = { return { "id": incident. row data.uniqueValue, "title": incident.short description.displayValue, "order": 0, "lane id": "lane " + incident.priority.value, "options": { "board type": "FREEFORM", "card type": "classic", "due date field": "due date", "menu actions": , "show card info": true, "show cover image": true, "show labels": true, "show sla": true }, "attachments": , "record": { "short description": { "display value": incident.short description.value }, "description": { "display value": incident.description.value }, }, "labels": { "active": true, "color": " FFFF00", "value": "Yellow dot" } , "checklist": { "items": { "complete": true } }, "sla": { "percentage": 23 }, "due date display value": "31/12/2023", } } ; api.setState "cardsData", return value ; } Notice "lane id": "lane " + incident.priority.value mirrors the id we built in SetLanes - this is what makes each incident's card render inside the lane matching its priority. Card and lane styling happens in two places, both visible in the scripts above: Lane-level styling via the style block inside each lane object set in SetLanes . The keys are CSS class names the component renders internally - sn-lane for the lane body and lane-footer-container for the sticky footer bar. We used this to fix each lane's width, add internal padding, enable vertical scrolling per lane, and cap lane height so the footer stays pinned more on that in Step 5 . Card appearance via the options and data fields inside each card object set in SetCards : card type: "classic" selects the fuller card layout vs. "compact" . show card info , show cover image , show labels , show sla toggle which visual regions of the card render. labels drives the colored label chips/dots on the card we set a yellow "Yellow dot" label as an example . sla.percentage drives the SLA progress indicator. checklist.items drives the checklist-complete badge. due date display value drives the due-date pill. For the full list of supported options keys on cards, lanes, cell headers, and swimlanes colors, variants, tooltips, menu actions, etc. , see the Part 2 configuration reference https://www.servicenow.com/community/next-experience-articles/kanban-board-component-part-2-configuration-reference/ta-p/3057376 - we only used a subset of what's available. By default a lane has no footer at all - the footer isn't just hidden, the component doesn't render one until you tell it which template to use. On the Kanban Board component's property panel, under Lane footer configuration → Vertical lanes footer template , set the value to sn-vtb-lane-footer . This is what actually switches the footer on; it corresponds to the laneFooterTag property on the component visible in the page definition as "laneFooterTag": "sn-vtb-lane-footer" . With that template wired up, the per-lane options.can add task: true we set in SetLanes controls whether an individual lane's footer shows the Add Card affordance a lane could have a footer template enabled at the board level but still opt out via can add task: false . We paired this with the sticky-footer styling from Step 4 - lane-footer-container: { position: "sticky", bottom: "30px" } combined with sn-lane: { height: "calc 100% - 58px ", overflow-y: "auto" } - so the footer stays pinned to the bottom of the lane while the cards inside scroll independently. The footer's button text defaults to "Add card". We override it per lane with add card footer button name in the lane options object - in our script we set it to "New Incident" to match the table the board represents: "options": { ... "can add task": true, "add card footer button name": "New Incident" } Clicking that button dispatches the board's VTB ADD CARD CLICKED action with the triggering card context in the payload. On our page we mapped that action to open a modal containing a record-creation form for incident , using the newIncidentSysID state parameter to pass the new record's sys id into the form controller once it's created. That modal wiring is its own topic and outside the scope of this article, but it's the reason the button exists in the first place. When a card is dragged to a new lane or position, the board fires sn visual board.NOW VISUAL BOARD CARD MOVED with a payload of { id, toLane, fromPosition, toPosition } . We mapped a client script Move cards to that event which: - Re-sequences order for both the destination lane and if the card crossed lanes the source lane, and writes the updated array back to cardsData so the UI reflects the move immediately. - Derives the incident's new impact / urgency from the destination lane's priority using a small lookup matrix. - Calls the update card GraphQL data resource configured with readEvaluationMode: EXPLICIT so it only runs when explicitly executed, not on every re-render to persist impact , urgency , and a work note back to the incident record. / @param {params} params @param {api} params.api @param {any} params.event @param {any} params.imports @param {ApiHelpers} params.helpers / function handler {api, event, helpers, imports} { const { state: { cardsData } } = api; const { payload: { id, toLane, fromPosition, toPosition } } = event; // Work on a shallow copy so we don't mutate state directly let cardsDataCopy = ...cardsData ; // Locate the card being moved const movedCardIndex = cardsDataCopy.findIndex c = c.id === id ; if movedCardIndex === -1 { return; } const movedCard = { ...cardsDataCopy movedCardIndex }; const fromLane = movedCard.lane id; // Pull it out of the working array cardsDataCopy.splice movedCardIndex, 1 ; // Assign new lane movedCard.lane id = toLane; // Cards currently in the destination lane, ordered let destLaneCards = cardsDataCopy .filter c = c.lane id === toLane .sort a, b = a.order - b.order ; // Insert moved card at its new position destLaneCards.splice toPosition, 0, movedCard ; // Re-sequence order values for destination lane destLaneCards.forEach c, idx = { c.order = idx; } ; // If it moved across lanes, re-sequence the source lane too let sourceLaneCards = ; if fromLane == toLane { sourceLaneCards = cardsDataCopy .filter c = c.lane id === fromLane .sort a, b = a.order - b.order ; sourceLaneCards.forEach c, idx = { c.order = idx; } ; } // Everything untouched other lanes const otherCards = cardsDataCopy.filter c = c.lane id == toLane && c.lane id == fromLane ; const updatedCards = ...otherCards, ...sourceLaneCards, ...destLaneCards ; api.setState "cardsData", updatedCards ; const priority = toLane.replace 'lane ', '' ; const fields = getImpactUrgencyForPriority priority ; if fields { console.warn 'Unrecognised lane, skipping update:', toLane ; return; } api.data.update card.execute { "table": "incident", "recordId": id, "templateFields": "impact=" + fields.impact + "^urgency=" + fields.urgency + "^work notes=Change by moving cards", "useDisplayValue": false } ; function getImpactUrgencyForPriority priority { const matrix = { "1": { impact: "1", urgency: "1" }, // Critical "2": { impact: "1", urgency: "2" }, // High "3": { impact: "2", urgency: "2" }, // Moderate "4": { impact: "2", urgency: "3" }, // Low "5": { impact: "3", urgency: "3" } // Planning }; return matrix String priority || null; } } Because priority on the incident table is derived from impact and urgency rather than being directly settable, this script reverse-maps the target lane back to an impact/urgency pair via a matrix, rather than writing to priority directly. The Lanes get an lane id contract is everything. id and cards get a matching lane id ; if the prefix/format ever diverges between your lanes script and your cards script, cards silently disappear from the board. Lanes and cards are just state. There's no dedicated "lane designer" UI - you populate lanesData / cardsData from any data resource and any transformation logic you want, which means the board can be driven by GlideRecord queries, Transforms, Scripted REST APIs, or GraphQL, not just the two examples here. Most visual configuration lives in , not in the Kanban component's own property panel. The options and style on the lane/card objects style keys map to internal CSS class names sn-lane , lane-footer-container , etc. , so styling is really "CSS-in-JSON" per lane or card.The board is purely presentational until you map a script to CARD MOVED is your write-back hook. CARD MOVED and optionally LANE MOVED to persist the reordering back to the source table. Everything the board can do beyond rendering lanes and cards — modals, archiving, drag-reordering, attachments, swimlane controls — is exposed as a fixed catalog of internal, "hidden" events on the daActionMapping property of the Now Visual Board macroponent. We only wired up one of these ourselves VTB ADD CARD CLICKED , in Step 6 , the same one shown as an example on the ServiceNow Community reference articles VTB CONFIRMATION MODAL SELECTED . To document the rest, we pulled the daActionMapping definition from the Kanban Board component's own out-of-the-box demo page and extracted it in full — this is the component's built-in default, not something we authored, so it's a reliable reference for every hidden event the board supports and the exact payload shape each one dispatches. Each entry has the same shape: assignmentId fixed, don't change it , name / actionDispatch the identifier you dispatch a declarative action against , actionType always uxf client action , label what shows up in the UI Builder action picker , and actionPayload a JSON template with {{handlebars}} placeholders showing the fields available on the event . A naming quirk to be aware of: for three events — CARD MOVED , LANE MOVED , and SWIMLANE MOVED — the key in the daActionMapping map is prefixed NOW VISUAL BOARD ... , while the name / actionDispatch value inside it is VTB ... . Those three are also the ones exposed as public events on the component's standard Events tab, under the API name sn visual board.NOW VISUAL BOARD