Shipping a campaign builder, where drag-and-drop is the easy part
An internal block builder for marketing Stories — one component tree that's both the editor and the live preview, drag-and-drop as a tree mutation, keyboard-accessible reordering, and a publish flow safe enough to hand to a non-engineer.
For two years, launching a marketing campaign in Mail.ru Cloud meant a ticket. A marketer wrote the copy and the offer in a doc, a designer mocked up the blocks, and a frontend engineer turned it into a “Story” — the full-screen promo cards on the home screen — wired the targeting, and shipped it behind a release. Two days, best case. Worst case it was two days plus a typo in the price, and then half a day more to fix the typo, because the fix was a release too.
By early 2025 we’d had enough and built an internal tool: a marketer assembles a Story from blocks, sees what the user will see, sets who gets it and when, and publishes it — no engineer in the loop. This is what that actually took. Most of the work wasn’t the drag-and-drop everyone pictures when they hear “builder”; the drag became a solved problem the minute we picked a library. The work was everywhere around it.
The bar for an internal tool
The bar here is different from a normal feature’s. A feature is done when it works. This was done only once marketing stopped opening tickets, which meant it had to hold up for the person who’d never seen it before — the one who’d drag a block somewhere I hadn’t planned for, or publish at 9pm with no engineer awake. I checked most decisions against that worst-case user, and the ones I got wrong were the ones where I’d quietly optimized for my own convenience instead of theirs.
One tree, both the editor and the preview
The editor and the preview render from the same component tree. One PromoCard, one PriceBlock, one ButtonBlock, each painting from the same props whether it’s being edited on the canvas or shown in the preview pane; the only thing that differs is a mode read from context.
The tempting alternative is an editor that emits some intermediate description and a separate renderer that turns it back into UI. That leaves you maintaining two code paths that are supposed to agree, and eventually they don’t: a block looks right while you edit it, you publish, and the live card has a different line height because the renderer lagged a refactor by a week. One tree means there’s nothing to drift.
// one component per block type — identical output in both modes. the selection ring
// and handle are absolutely-positioned overlays, so edit mode never shifts the layout.
const Mode = createContext<'edit' | 'preview'>('preview');
function PriceBlock({ block }: { block: PriceBlockData }) {
const mode = useContext(Mode);
const selected = useSelected(block.id);
// .block is position: relative in both modes, so the overlay below changes
// nothing about the layout that ships.
return (
<div className="block">
<p className="price">{formatPrice(block.amount, block.currency)}</p>
{mode === 'edit' && selected && <span className="sel-overlay" aria-hidden />}
</div>
);
}
Two things make that work. First, the edit chrome — the selection ring, the drag handle, the ”+” between blocks — has to stay out of the box model. Add a 2px border on selection and every block jumps 2px when you click it, and the preview is now lying about spacing. So the chrome is an overlay over the block, drawn with outline and absolutely-positioned handles; the block’s own layout is exactly what ships, and selecting it only adds something on top.
Second, because it’s one React tree over one state, the preview tracks edits as you type — with the block’s state colocated and the block memoized, a keystroke re-renders that block, not the whole document. I got the isolation wrong on the first cut. I put the preview in an iframe, the textbook way to keep the admin panel’s CSS out of the Story, and typing lagged: every keystroke serialized the document, posted it across the frame boundary, and re-rendered from scratch, so the preview trailed the cursor by a beat. I pulled it back into the same tree and isolated it with a Shadow DOM the preview subtree renders into through a portal, the design system’s stylesheet adopted into the shadow root. (React’s synthetic events don’t reliably cross a shadow boundary through a portal, which is usually the catch here; it didn’t bite, because the interactive chrome stays in the light-DOM overlay and only the non-interactive render goes into the shadow root.) The cost is that I inject those styles myself, where the iframe would have handed me a clean document for free.
Turning a drag into a tree edit
A campaign is a tree: a Story holds blocks, and some blocks are containers for other blocks — a row that puts two cards side by side, a group with a shared background. So a drop is never just “moved here”; it’s an edit to the tree — reorder within a parent, or reparent into another container at a specific index. The pointer is only how the user points at the edit they mean.
// a campaign is a tree of blocks. children live on container blocks; everything is
// addressed by id, so a move is "reparent + reindex", never a copy of the subtree.
type Block =
| { id: string; type: 'text'; props: TextProps }
| { id: string; type: 'price'; props: PriceProps }
| { id: string; type: 'button'; props: ButtonProps }
| { id: string; type: 'row'; props: RowProps; children: string[] }; // container
type Doc = { root: string[]; blocks: Record<string, Block> }; // normalized, id-addressed
dnd-kit handles the gesture: the pointer and keyboard sensors, collision detection, the moving overlay. What it leaves to you is the question that matters at drop time — which parent, which index. For a flat list that’s a single number. For a tree it’s a projection: you read the drag’s horizontal offset to decide depth (drag right to nest under the block above, left to pop out a level), clamp it to the nestings the target allows, and only then resolve a parent and an index. dnd-kit ships this as its sortable-tree example, and re-deriving it for our block rules was most of the real DnD work.
// drop → tree edit: detach from the old parent, insert at the resolved (parent, index).
// the off-by-one below is the whole game — miss it and the block lands one slot from
// where the indicator promised, on every reorder downward within the same parent.
function move(doc: Doc, id: string, toParent: string | 'root', index: number): Doc {
const from = parentList(doc, id); // the array that holds id right now
const to =
toParent === 'root'
? doc.root
: (doc.blocks[toParent] as Extract<Block, { type: 'row' }>).children;
const oldIndex = from.indexOf(id);
from.splice(oldIndex, 1); // detach (in real code: clone, don't mutate)
// same array, moving down? the detach above already shifted the target left by one.
const at = from === to && oldIndex < index ? index - 1 : index;
to.splice(at, 0, id); // reattach exactly where the indicator pointed
return doc;
}
That off-by-one isn’t hypothetical — it shipped. For about a week, dragging a block downward inside the same group landed it one slot above where the indicator had shown, until a marketer messaged me that “the block won’t go where I drop it.” The fix is the three lines above. The lesson was blunter: the drop indicator is a promise, and the projection that draws it has to resolve the exact parent and index the drop will use, or people stop trusting the canvas almost immediately. I spent more time on that projection than on the drag itself.
Drag-and-drop that works without a mouse
A drag-and-drop interface built only on pointer events is unusable by keyboard and by a screen reader, and at VK accessibility isn’t optional, so that alone made the first version unshippable. Reordering is a core action; if it only works with a mouse, a real slice of users can’t do it at all.
Keyboard reordering is its own interaction you have to build: focus a block’s drag handle, space to pick it up, arrow keys to move it through the valid positions, space to drop, escape to cancel and snap back. dnd-kit’s keyboard sensor gives you the mechanics, but you still make each handle a real focusable button with a label (“Reorder price block”), express the movement in terms of the tree’s valid positions rather than raw pixels, and return focus to the moved block after the drop so the user isn’t dumped at the top of the document.
A screen reader has to be told all of this, or it stays silent. So a polite live region narrates each step — “Picked up price block, position 2 of 5,” “Moved to position 3 of 5,” “Dropped.” dnd-kit drives this through its announcements API; the work is writing announcements that describe the tree the marketer is editing rather than generic “item moved” strings.
One thing worth saying, because it’s the first thing people reach for and it’s wrong: aria-grabbed and aria-dropeffect look like they’re built for exactly this, and they’re deprecated and effectively unsupported. What works in real screen readers is the focus-managed keyboard interaction plus the live-region announcer. aria-grabbed will pass a quick spec check and tell an actual user nothing.
And honor prefers-reduced-motion: the drag’s spring animation is pleasant for most people and makes a few feel sick, so under reduced-motion the block jumps to its position instead of gliding there.
Draft, validate, publish, roll back
All of that is wasted if publishing isn’t safe, and “safe” for a non-engineer is three things: it won’t let me publish something broken, it shows me what’s broken, and if a live campaign turns out wrong I can pull it back myself without finding an engineer.
Validation runs against a schema, one per block type, and a document can’t reach “publish” until it passes — a price block with no amount, a button with no link, a Story with no blocks are all caught first. What matters is where the error shows up: on the block, next to the fix, instead of a “something’s wrong” banner at the top. A schema makes that cheap, because it knows which field of which block failed.
// one schema per block; the document validates before "publish" unlocks, and the
// error carries the block id so it surfaces on the block, not in a global banner.
const PriceSchema = z.object({
amount: z.number().positive(),
currency: z.enum(['RUB', 'USD', 'EUR']),
});
const result = PriceSchema.safeParse(block.props);
if (!result.success) markInvalid(block.id, result.error); // pin the error to the block
Publishing isn’t a single on/off. A campaign moves through draft → scheduled → live → archived; “scheduled” carries a start and an optional end, and “live” is per-audience, because targeting runs through feature flags — the same campaign can be live for 5% of users and invisible to everyone else, then ramped. Liveness is always “live for whom,” and building the state model around an audience-scoped flag from the start kept us from bolting targeting onto a global on/off later.
Rollback is what makes the rest trustworthy. Published content is an immutable, versioned snapshot, and “live” is a pointer to a version, so publishing pins a new version and rollback just re-points to the previous one — instant, content-only, no redeploy. We leaned on it the first week: a campaign went out with a broken offer link, and instead of a hotfix release it was a re-point to the previous version, a corrected link, and a republish. The version that was live an hour earlier was still sitting there, whole.
What I check before I call a builder “done”
- a marketer who’s never seen it can build and ship a real campaign with no engineer involved — tested with an actual person, not assumed;
- the editor and the preview are one component tree, and the edit chrome is an overlay that never moves the block’s layout;
- every drag works end to end from the keyboard, focus lands back on the moved block, and each step is announced to a screen reader (and there’s no
aria-grabbedin the codebase); - the document validates per-block before publish unlocks, with each error pinned to the block that owns it;
- “live” is audience-scoped through a flag, and a rollback is an instant re-point to a previous immutable version.
I wouldn’t call it finished. Cross-container moves by keyboard still take more keypresses than they should, and there’s a layout case in deeply nested rows I worked around instead of solving. But the thing it was built for happened: the two-day ticket became an afternoon a marketer mostly spends writing copy, the broken-link panic became a rollback, and the queue of campaign tickets pointed at engineering dried up. I’d make that trade again.