Drag-and-drop reordering for the children of Streamlit containers.
Project description
streamlit-dnd
Drag-and-drop reordering for the direct children of Streamlit containers — reorder items inside a container or move them between containers. Arrangements are applied to st.session_state (and, in the demo, mirrored to disk so they survive page refreshes and app restarts).
Built and tested against Streamlit 1.58.
Try the live demo to play with every option in the browser, no install needed.
Install
pip install streamlit-dnd
That's it — the frontend ships inside the package, so there's no build step and nothing else to configure. Import and use it like any other Streamlit component:
from streamlit_dnd import dnd, apply_move
Run the demo
Try the hosted demo, or run the bundled demo from a clone of this repo:
pip install -r requirements.txt
streamlit run demo.py
Usage
import streamlit as st
from streamlit_dnd import dnd, apply_move
if "items" not in st.session_state:
st.session_state.items = {"left": ["A", "B", "C"], "right": ["D"]}
# 1. Render keyed containers whose children come from session state
col1, col2 = st.columns(2)
with col1, st.container(key="left", border=True):
for it in st.session_state.items["left"]:
with st.container(key=f"item_{it}", border=True):
st.write(it)
with col2, st.container(key="right", border=True):
for it in st.session_state.items["right"]:
with st.container(key=f"item_{it}", border=True):
st.write(it)
# 2. Enable drag and drop on those containers (call AFTER rendering them)
event = dnd("left", "right")
# 3. Apply drops to session state and rerun
if event:
apply_move(event, st.session_state.items)
st.rerun()
API
dnd(*container_keys, **options) -> DropEvent | None
| Parameter | Type | Default | Description |
|---|---|---|---|
*container_keys |
str / iterables of str |
— | Keys of the st.container(key=...) blocks to enable dnd on. |
cross |
bool |
True |
Allow dragging items between containers. False = reorder within each container only. Ignored when sources/destinations are set. |
sources |
list[str] | None |
None |
If set, only these containers' items can be dragged. |
destinations |
list[str] | None |
None |
If set, items can only be dropped into these containers. |
exclude |
list[str] | None |
None |
Keys of child elements that must never be draggable (matched against each item's key=). Excluded items are pinned in place and ignored by the drop-position math — handy for fixed headers or other non-draggable content inside a draggable container. |
placeholder |
str | dict[str, str] | None |
None |
Dimmed, italic hint shown inside a container while it has no draggable items (e.g. "Drop items here"). The component injects/removes it automatically. Pass one string for all containers, or a {container_key: text} mapping for per-container messages. |
handle |
bool | "border" |
"border" |
"border": the item's edges become the handle (grab from a band around the border, interior stays free for buttons/inputs). False: grab items anywhere. True: items get a small corner drag handle and only drag from it. |
handle_corner |
"top-right" | "top-left" | "bottom-right" | "bottom-left" |
"top-right" |
Which corner the handle icon sits in when handle=True. |
handle_icon |
str |
"⠿" |
What the corner handle shows: any text/emoji, or a Streamlit Material icon as ":material/<name>:" (e.g. ":material/drag_indicator:"). Applies when handle=True. |
indicator |
"line" | "highlight" | "ghost" |
"ghost" |
"ghost": inserts a translucent copy of the dragged item at the drop position — the list reflows to preview the result, and on drop the copy seamlessly becomes the real item. "line": bright insertion line between items showing where the drop lands. "highlight": tints the element whose spot will be taken. |
color |
str |
"#ff4b4b" |
Any CSS color for the indicator. |
key |
str |
"stdnd" |
Component instance key. Set explicitly when calling dnd() more than once per page. |
Returns a DropEvent for each completed drop (then None until the next drop):
@dataclass(frozen=True)
class DropEvent:
from_container: str # container key the item left
to_container: str # container key the item entered (== from_container for reorders)
item_key: str | None # st key of the dragged element (None if unkeyed)
from_index: int # position before the move
to_index: int # insertion position (pre-removal indexing for same-container moves)
apply_move(event, lists) -> None
Convenience helper that applies a DropEvent to plain Python lists in place, handling the same-container index shift:
apply_move(event, {"left": st.session_state.left, "right": st.session_state.right})
Recipes
Reorder only (no cross-container moves):
dnd("my_list", cross=False)
Source → destination flow (e.g. a palette you drag items out of, into a canvas):
dnd("palette", "canvas", sources=["palette", "canvas"], destinations=["canvas"])
sources lists who can be dragged from, destinations who can be dropped into. A container in both lists supports internal reordering too.
Items with interactive widgets inside:
dnd("board", handle=True) # drag only via the corner handle
# pick the corner and the icon (text, emoji, or a Material icon)
dnd("board", handle=True, handle_corner="bottom-left",
handle_icon=":material/drag_indicator:")
# or make the item's border the handle, leaving the interior free
dnd("board", handle="border")
Multiple independent dnd groups on one page:
ev1 = dnd("group1_a", "group1_b", key="dnd_group1")
ev2 = dnd("group2_a", "group2_b", key="dnd_group2")
Trello-style ghost preview:
dnd("board", indicator="ghost")
While dragging, a translucent dashed-outline copy of the item is inserted at the prospective position so the list reflows to show the would-be result. On drop, that copy instantly turns into the real item (full opacity, interactive) and the original collapses; when Streamlit's rerender lands a moment later, the copy is swapped for the genuine re-rendered element with no visual gap.
Persisting arrangements across page refreshes:
st.session_state is per-session: a page refresh, a new tab, or an app
restart starts a fresh session and wipes it. To make arrangements durable,
mirror them to storage (a file, database, etc.) on every drop and seed new
sessions from it:
import json, copy
from pathlib import Path
STORE = Path(__file__).parent / "arrangements.json"
DEFAULTS = {"left": ["A", "B", "C"], "right": ["D"]}
def save():
tmp = STORE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(st.session_state.items))
tmp.replace(STORE) # atomic write
# Seed new sessions from disk (or defaults)
if "items" not in st.session_state:
st.session_state.items = (
json.loads(STORE.read_text()) if STORE.exists() else copy.deepcopy(DEFAULTS)
)
# ... render containers ...
event = dnd("left", "right")
if event:
apply_move(event, st.session_state.items)
save() # <- mirror the change to disk
st.rerun()
# Reset = delete the store + restore defaults
if st.button("Reset"):
STORE.unlink(missing_ok=True)
st.session_state.items = copy.deepcopy(DEFAULTS)
st.rerun()
demo.py implements exactly this pattern (see "Persistence" section at the
top of the file). Note: a plain JSON file is shared by all visitors of the
app — for multi-user apps, key the storage by user (e.g. st.user.email) or
use a database.
How it works
Streamlit adds a CSS class st-key-<key> to every keyed element and container.
This module mounts an invisible custom component (a same-origin iframe) that:
- Reaches into the parent document (
window.parent.document) — possible because Streamlit serves component iframes from the same origin withallow-same-origin. - Finds your containers via
.st-key-<key>and identifies their direct children: in Streamlit 1.58's DOM, every visual child of a container is a direct DOM child that is either adiv[data-testid="stElementContainer"](simple elements/widgets) or adiv[data-testid="stLayoutWrapper"](nested containers, expanders). - Wires native HTML5 drag-and-drop handlers onto those children, draws the drop indicators, and enforces the cross/sources/destinations rules.
- On drop, sends
{from_container, to_container, item_key, from_index, to_index}back to Python viaStreamlit.setComponentValue, which triggers a rerun — your script applies the move tost.session_stateand re-renders. - A
MutationObserverre-wires everything after each Streamlit rerun (Streamlit recreates DOM nodes), so dnd keeps working across reruns.
┌────────────────────────── parent document ──────────────────────────┐
│ div.st-key-left (stVerticalBlock) div.st-key-right │
│ ├─ div[stLayoutWrapper] ◄─── draggable ├─ div[stLayoutWrapper] │
│ │ └─ div.st-key-item_A │ └─ div.st-key-item_D │
│ ├─ div[stLayoutWrapper] ◄─── draggable └─ ... │
│ │ └─ div.st-key-item_B │
│ └─ ... │
│ │
│ ┌─ invisible iframe (this component) ─┐ │
│ │ wires dnd onto the elements above, │ │
│ │ reports drops to Python │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Caveats
- DOM coupling: this relies on Streamlit's internal DOM structure
(
stElementContainer/stLayoutWrappertest ids andst-key-*classes). It is verified against Streamlit 1.58; future Streamlit versions may need small selector updates instreamlit_dnd/frontend/main.js. - Item identity: give every draggable child its own
key=(the easiest, most robust pattern: make each draggable item a keyedst.container). Unkeyed children still drag, butDropEvent.item_keywill beNoneand you'll have to rely on indices alone. - Render before dnd: call
dnd()after the containers it targets have been rendered in the script.
Project layout
streamlit-dnd/
├── demo.py # full-featured demo (kanban, playlist, widget board)
├── streamlit_dnd/ # the reusable module
│ ├── __init__.py # dnd(), DropEvent, apply_move()
│ └── frontend/
│ ├── index.html # component scaffold (no build step needed)
│ ├── streamlit-protocol.js# minimal Streamlit component protocol
│ └── main.js # the dnd engine (parent-DOM wiring)
├── tests/
│ ├── test_apply_move.py # unit tests for index math
│ ├── minimal_app.py # minimal app for e2e testing
│ ├── e2e_module.py # Playwright e2e: wiring + simulated drag
│ ├── e2e_demo.py # Playwright e2e: full demo verification
│ ├── e2e_ghost.py # Playwright e2e: ghost indicator lifecycle
│ └── e2e_persistence.py # Playwright e2e: refresh persistence + reset
└── probe/ # DOM-discovery scripts used during development
Running the tests
# Unit tests
python tests/test_apply_move.py
# E2E (needs playwright + chromium)
streamlit run tests/minimal_app.py --server.port 8599 --server.headless true &
python tests/e2e_module.py
streamlit run demo.py --server.port 8599 --server.headless true &
python tests/e2e_demo.py
python tests/e2e_ghost.py
python tests/e2e_persistence.py
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file streamlit_dnd-0.1.1.tar.gz.
File metadata
- Download URL: streamlit_dnd-0.1.1.tar.gz
- Upload date:
- Size: 30.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e50c627062e222cf29c18cbb5d08f94157d73dc30dcda80546ba963ff36d847
|
|
| MD5 |
7d11417a49c780ad040c374860b487fe
|
|
| BLAKE2b-256 |
dcbd258835fa238c949bb5e0a4d1641c77f4b1bae05d3df4b9288b3df71370e5
|
Provenance
The following attestation bundles were made for streamlit_dnd-0.1.1.tar.gz:
Publisher:
publish.yml on bouzidanas/streamlit-dnd
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
streamlit_dnd-0.1.1.tar.gz -
Subject digest:
8e50c627062e222cf29c18cbb5d08f94157d73dc30dcda80546ba963ff36d847 - Sigstore transparency entry: 1844341170
- Sigstore integration time:
-
Permalink:
bouzidanas/streamlit-dnd@7b812b5b16319c3e270e40bed3574057aba9843a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/bouzidanas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7b812b5b16319c3e270e40bed3574057aba9843a -
Trigger Event:
push
-
Statement type:
File details
Details for the file streamlit_dnd-0.1.1-py3-none-any.whl.
File metadata
- Download URL: streamlit_dnd-0.1.1-py3-none-any.whl
- Upload date:
- Size: 27.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e064b5853d77d2d6f5eed8b48d9366f7e2dc623d4b73c2659a6b0a54b221fe1
|
|
| MD5 |
f346797123ceb595e8e6e03997a4fe3e
|
|
| BLAKE2b-256 |
e3b33f3ef547016a9617e193b64b6070bd212b29a7f730ee9bf1c7e95e73b7f8
|
Provenance
The following attestation bundles were made for streamlit_dnd-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on bouzidanas/streamlit-dnd
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
streamlit_dnd-0.1.1-py3-none-any.whl -
Subject digest:
5e064b5853d77d2d6f5eed8b48d9366f7e2dc623d4b73c2659a6b0a54b221fe1 - Sigstore transparency entry: 1844341323
- Sigstore integration time:
-
Permalink:
bouzidanas/streamlit-dnd@7b812b5b16319c3e270e40bed3574057aba9843a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/bouzidanas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7b812b5b16319c3e270e40bed3574057aba9843a -
Trigger Event:
push
-
Statement type: