A Python library for visualizing protein, DNA, and RNA structures in 2D, designed for Google Colab and Jupyter.
Bonus: online interactive version
pip install py2Dmolpip install git+https://github.com/sokrypton/py2Dmol.gitA browser with WebGL2, which every current browser has. The notebook draws on the GPU: turning and zooming cost one draw call rather than a full repaint, which is what makes a large structure usable — 26 ms a frame on a 313,000-atom capsid, against 840 ms without it.
py2Dmol has two modes—decided by when you call show():
- Static:
add*()thenshow()→ one self-contained viewer. - Live:
show()thenadd*()→ stream frames/points as you go.
import py2Dmol
viewer = py2Dmol.view()
viewer.add_pdb('6MRR')
viewer.show()import py2Dmol
viewer = py2Dmol.view()
viewer.show()viewer.add_pdb('6MRR')py2Dmol.view(autoplay=True).from_pdb('1YNE') # ensemble
py2Dmol.view(rotate=True).from_pdb('1BJP', use_biounit=True) # biounit
py2Dmol.view().from_pdb('9D2J') # multi-chain
py2Dmol.view(pae=True).from_afdb('Q5VSL9') # AlphaFold + pAEviewer = py2Dmol.view(
size=(300, 300), color='auto', colorblind=False,
style='tube', # or 'cartoon' for secondary-structure cartoons
shadow=True, outline='full', width=3.0, ortho=1.0,
rotate=False, autoplay=False, box=True, controls=True,
)
viewer.add_pdb("my_complex.cif")
viewer.show()Four styles, switchable live from the Style dropdown:
tube(default) — the classic py2Dmol smooth backbone trace.richardson— the hand-drawn look of Jane Richardson's protein drawings: flat wide helices, thick arrowheaded strands with white card edges, and coloured-pencil paper grain.ribbon— plain flat cartoon: twisted ribbons for helices, arrowhead plates for strands, thin tubes for loops, and none of the above.3d— solid shaded geometry, on a black background. Passbg=to override.
The last three are all cartoons, and each is a starting point that loads into
the normal controls — the sliders stay live for tweaking under any of them.
style="cartoon" still works and means richardson.
py2Dmol.view(style='richardson').from_pdb('1A3N', use_biounit=True)
py2Dmol.view(style='richardson', color='ss').from_pdb('1TIM')
py2Dmol.view(style='ribbon').from_pdb('1TIM') # plain cartoon
py2Dmol.view(style='3d').from_pdb('1TIM') # solid, on black
py2Dmol.view(style='richardson', pencil=0, sheet_flat=0).from_pdb('1TIM')An explicit argument always wins over the style's own defaults. Both styles work on C-alpha-only models — the backbone, its secondary structure, and where nucleic
bases point are all rebuilt from the trace, with nothing per-residue stored or
shipped. tests/README.md has the accuracy numbers.
All are view() arguments and all have a slider in the panel:
thickness |
slab thickness in Å (0 = flat ribbons). Tapers off as you zoom out. |
width |
overall ribbon scale |
shade |
0–1, how much directional modelling: 0 is flat colour, 1 full light and inner shadow |
highlight |
specular band, not scaled by shade — so shade=0, highlight=2 is flat colour with a highlight on top |
detail |
2–8 subdivisions per residue. Exactly this, at every canvas size, zoom and structure size. |
arrows |
arrowhead on each strand's C-terminal end (default on) |
sheet_flat |
0–1, damps the β-pleat and smooths loops |
pencil |
0–1, coloured-pencil paper grain, on the structure only |
outline |
width in pixels; fractional values are real, and it thins to a hairline before 0 turns it off |
color='ss' colours by secondary structure, with the palette set by ss_palette
or the SSE dropdown: pymol (default) or jmol. It works with any style.
SVG and PNG export reproduce all of this, grain and gradients included.
Draw (in the Style panel) builds the picture up the way an illustrator makes one: a pencil line first, then colour over it, slightly off register. It ends on watercolour over pencil and stays there; turning it off returns the ordinary picture, pressing it again replays from blank paper. The view stays live while it draws. Cartoon style only.
The camera button writes a PNG or SVG. PNG takes a DPI — 300 dpi on a 600px view renders at 1875x1875 rather than scaling up — and the background is always transparent. Shift-click skips the panel.
With Rotate or Draw on, the same button records a video instead: one seamless full turn, or the drawing being made.
Moving the view. Drag to rotate, scroll to zoom, middle-drag or Cmd/Ctrl-drag to pan, as in PyMOL. Panning moves the rotation centre, so rotation and zoom keep working about the point you dragged to.
Aiming the camera from Python. A viewer turns to face the reader once, by
itself, when the first frame lands. orient asks for it again — after a
colour, after a clip, after the reader has spun it somewhere unhelpful.
viewer.orient() # best view of what is on screen
viewer.orient(chain="B") # ...of chain B
viewer.orient(position=(40, 60)) # ...of a loop, close up
viewer.orient(chain="B", animate=False) # jump instead of flyingIt orients on what you can see: a hidden residue cannot pull the view towards itself. The Orient button in the viewer, on the website and in an embed all run the same search.
Side chains. A notebook payload carries one position per residue, which is
all the tube and the cartoon need. py2Dmol.view(sidechains=True) carries the
side-chain atoms as well, so they can be drawn and so focus can measure side
chain to side chain. It costs about four times the coordinates per frame
(a 251-residue design: 9.0 KB a frame becomes 37.2), so it is off by default
and worth thinking about before loading a long trajectory.
Focusing on one thing. focus is the click-and-look-at-it move: it selects
the residue or ligand, draws the side chains of everything within 5 Å of it,
moves the camera in, and cuts a slab around it. The next focus replaces all
four, so you can walk from one residue to the next without side chains piling
up behind you.
viewer.focus(chain="C", position=0) # a ligand and its pocket
viewer.focus(position=(40, 41)) # one residue, close up
viewer.focus() # back out againIt does not turn the structure — only the centre and the zoom move, so you
keep your bearings. That is the difference between focus and orient. The
camera glides rather than jumping.
The Focus button beside Style does the same thing on every click: press it, click a residue or a ligand, click the next one, and click the background to come back out.
Cutting into it. clip keeps a slab as deep as a selection and holds it
there as the structure turns — so to cut deeper, clip to less.
viewer.clip(position=(40, 60)) # a slab over residues 40–59
viewer.clip(chain="B") # ...as deep as chain B
viewer.clip() # offThe depth is the selection's own depth along the view; there is no thickness to set, and the renderer refits it every frame. The Clip button does the same thing to whatever is selected.
Bonds in a ligand. Most files do not list them — mmCIF's _struct_conn
carries the exceptions, not the ordinary bonds inside a residue — so they are
derived from distance, per pair of elements: S–S out to 2.4 Å, C–O to 1.65,
C–I to 2.4. Pass position_elements with your coordinates and that table is
used; without it every pair falls back to one number, cutoffs["ligand_bond"]
(2.0 Å), which misses a disulfide at 2.05 and joins an O···O contact at 1.9.
viewer.add(xyz, position_types=['L'] * 4,
position_elements=['S', 'S', 'O', 'O'])
viewer.add(xyz, bonds=[[0, 1]]) # ...or say so outrightSetting cutoffs={"ligand_bond": 2.2} still means that number for every pair,
as it always did. Note the element is not the atom name — in 3PTB CA is both
the alpha carbon and the calcium ion — and only the element travels: an atom's
name was carried everywhere and read by nothing, so it was dropped.
Showing side chains. focus draws a neighbourhood's for you; to name them
yourself, ask.
viewer = py2Dmol.view(sidechains=True) # carries the atoms — see below
viewer.add_pdb('3PTB.cif')
viewer.show_sidechains(position=(40, 60))
viewer.hide_sidechains(position=45) # ...all but that one
viewer.hide_sidechains() # off againBoth are relative — show adds to what is drawn and hide takes away, and with
nothing named either means every residue. view(sidechains=True) is what makes
it possible at all: side-chain atoms are coordinates, so they are a per-frame
cost (a 251-residue design goes from 9.0 KB a frame to 37.2) and they are not
sent unless you ask. Without them there is nothing to draw and the call says so.
Fetching a chain. The fetch box takes a chain suffix: 1timA, 1TIM_A,
1tim_AB (one chain per character) or 1tim:A,B (commas for multi-character
chain IDs). Only four-character PDB IDs take a suffix, which keeps a UniProt
accession like Q5VSL9 from being read as an ID plus chains.
viewer = py2Dmol.view()
viewer.add_pdb('simulation1.pdb', name="sim1")
viewer.add_pdb('simulation2.pdb', name="sim2") # creates a new object
viewer.show() # switch via dropdownThe dropdown shows one object at a time. To put two structures in the same picture — the same thing the website's Multi button does — ask for it:
viewer = py2Dmol.view(multi=True) # every object, including later ones
viewer.add_pdb('apo.pdb', name="apo")
viewer.add_pdb('holo.pdb', name="holo")
viewer.show()or name a set after the fact:
viewer.show_objects() # every object loaded
viewer.show_objects(["apo", "holo"]) # those two
viewer.show_objects("apo") # back to oneThe camera widens once to take in whatever is newly on screen. multi=True is
a standing instruction — an object added later joins the picture — and naming a
set replaces it. Note this is a different question from overlay=True, which
shows every frame of one object.
The picker is hidden while there is only one object to pick.
The grid carries size, controls, box and bg as defaults for every
viewer in it, and any g.view(...) can override them.
with py2Dmol.grid(cols=2, size=(300, 300), bg="black") as g:
g.view().from_pdb('1YNE')
g.view().from_pdb('1BJP')
g.view().from_pdb('9D2J')
g.view().from_pdb('2BEG')Visualize per-frame 2D data (RMSD vs energy, PCA, etc.) synced to the trajectory. Scatter highlights the current frame and is clickable to jump frames.
# Trajectory with scatter points
viewer = py2Dmol.view(scatter=True, scatter_size=300)
viewer.add_pdb(
"trajectory.pdb",
scatter=trajectory_scatter_points, # list/array of [x, y] per frame (or path to CSV with x,y; first row used as labels if present)
scatter_config={"xlabel": "RMSD (Å)", "ylabel": "Energy (kcal/mol)", "xlim": [0, 10], "ylim": [-150, -90]},
)
viewer.show()CSV with trajectory
viewer = py2Dmol.view(scatter=True)
viewer.add_pdb('trajectory.pdb', scatter='data.csv') # header used to set axis labels
viewer.show()Data sources
- Array/list: per-frame
scatter=[x, y](list/tuple/dict) or a 2-column array with one row per frame. - CSV file: two numeric columns; optional header row sets
xlabel,ylabel. Example:RMSD,Energy 1.2,-150.3 1.4,-149.8 1.6,-149.1
No Python, no build step — one script tag and one call:
<div id="mol" style="width:400px;height:400px"></div>
<script src="https://py2dmol.solab.org/py2Dmol/resources/bundles/py2Dmol.embed.min.js"></script>
<script>
fetch('https://files.rcsb.org/download/1UBQ.pdb')
.then((r) => r.text())
.then((text) => {
const v = py2Dmol.show('mol', text);
v.setStyle('cartoon');
});
</script>show() works out whether it was handed a PDB or an mmCIF by looking at it and
returns the viewer. Beyond setStyle and setFrame you get setColor (a mode,
or any colour on a chain, a list of positions or a range), setContacts
(lines between residues, weighted and coloured), showObjects (two structures
in one picture) and select. Pass controls: true for the same Style panel the
notebook has, and the frame strip appears by itself for a multi-model file.
Two bundles, differing only in the painter:
| file | size | |
|---|---|---|
py2Dmol.embed.min.js |
453 KB | WebGL2. Fast on large structures. |
py2Dmol.embed.cpu.min.js |
414 KB | 2D canvas. No WebGL2 needed, and it can export SVG. |
Neither carries the control panel, the save UI or the side panels; for those, load the full application. Neither falls back to the other — each has one painter and nothing behind it.
Contacts are colored lines between residues; width follows weight.
File formats (.cst)
idx1 idx2 weight [color](0-based)chain1 res1 chain2 res2 weight [color]
Data sources
- Array/list: list/array of
[idx1, idx2, weight]or[idx1, idx2, weight, {r,g,b}](0-based indices). - File:
.csttext file, one contact per line (formats above).
Add contacts
viewer = py2Dmol.view()
viewer.add_pdb('structure.pdb', contacts='contacts.cst')
viewer.show()Rendering uses a fixed 25% white mix to soften colors (DeepMind palette remains unlightened); there is no user-facing pastel/lightening setting.
Five-level priority: Global (view(color=...)) < Object < Frame < Chain < Position.
Semantic modes: auto, chain, plddt, rainbow, entropy, deepmind
Literal: named, hex, or {"r":255,"g":0,"b":0}
How to target colors
- Position:
set_color("red", position=10)orposition=(start, end) - Chain:
set_color("red", chain="A") - Frame:
add(..., color="rainbow")on a single frame - Object:
set_color({"type": "mode", "value": "plddt"}, name="obj1") - Global:
view(color="chain")
viewer = py2Dmol.view(color="plddt")
viewer.add_pdb("protein.pdb")
viewer.set_color("red", chain="A")
viewer.set_color("yellow", position=(0, 20))
viewer.set_color("red", chain="A", position=10, frame=0)
viewer.show()An explicit colour always beats a mode. Setting one residue red keeps it red under
plddt, chain, rainbow or an SSE palette — the mode only decides the colour of
residues you have not spoken for.
The automatic assignment is good but not infallible, and a figure sometimes wants a
region drawn a particular way regardless. set_sse forces it, and is the same override
the web interface's SSE control writes:
viewer.set_sse("H", position=(20, 35)) # force 20-34 to helix
viewer.set_sse("E", chain="B") # all of chain B as strand
viewer.set_sse(None, position=(20, 35)) # clear - back to automatic"H" helix, "E" strand, "C" loop, None to clear. Stored on the object as
sse, beside color, and keyed by position index - so like a colour override it
belongs to that object's numbering.
Drag across the sequence strip to select residues, or click a chain label to select the whole chain; a yellow outline marks the selection in both the strip and the structure. Clicking empty space clears it. Selecting never changes what is visible — showing and hiding are separate, explicit actions.
The tools then act on that selection: Colour (the chain colour palette plus
white/grey/black, and Auto to drop back to the colour mode), SSE
(helix/sheet/loop/auto),
Show / Hide, and Copy, which extracts the selection into a new object.
Select all and Unselect are next to them. Everything here writes the same
structures set_color and set_sse write from Python, so a session set up either way
looks the same.
Build mixed systems (protein/DNA/ligand) with explicit atom types.
import numpy as np, py2Dmol
def helix(n, radius=2.3, rise=1.5, rotation=100):
angles = np.radians(rotation) * np.arange(n)
return np.column_stack([radius*np.cos(angles), radius*np.sin(angles), rise*np.arange(n)])
protein = helix(50); protein[:,0] += 15
dna = helix(30, radius=10, rise=3.4, rotation=36); dna[:,0] -= 15
angles = np.linspace(0, 2*np.pi, 6, endpoint=False)
ligand = np.column_stack([1.4*np.cos(angles), 1.4*np.sin(angles), np.full(6, 40)])
coords = np.vstack([protein, dna, ligand])
plddts = np.concatenate([np.full(50, 90), np.full(30, 85), np.full(6, 70)])
chains = ['A']*50 + ['B']*30 + ['L']*6
types = ['P']*50 + ['D']*30 + ['L']*6
viewer = py2Dmol.view((400,300), rotate=True)
viewer.add(coords, plddts, chains, types)
viewer.show()import numpy as np
viewer = py2Dmol.view(autoplay=True)
viewer.show()
angles = np.linspace(0, 2 * np.pi, 20, endpoint=False)
for frame in range(60):
coords = np.column_stack([
4 * np.sin(2 * angles + frame * 4 * np.pi/60),
12 * np.cos(angles + frame * np.pi/60),
12 * np.sin(angles + frame * np.pi/60)
])
viewer.add(coords)Save or restore full viewer state (structures, settings, MSA, contacts, frame/object selection).
viewer = py2Dmol.view(size=(600, 600), shadow=True)
viewer.add_pdb('protein.pdb'); viewer.show()
viewer.save_state('my_visualization.json')
viewer2 = py2Dmol.view()
viewer2.load_state('my_visualization.json')
viewer2.show()viewer = py2Dmol.view()
viewer.show()
viewer.add(coords1) # Cell #1
viewer.add(coords2) # Cell #2
viewer.replace(coords3) # Updates Cell #2, replaces last frameControl output cell behavior with the persistence parameter:
viewer.view(persistence=True): Default - Building trajectories, want visible historyviewer.view(persistence=False): Animations, temporary viz, avoid notebook bloat
Atom codes: Protein=P (CA), DNA=D (C4'), RNA=R (C4'), Ligand=L (heavy atoms)
Bond thresholds: Protein CA-CA 5.0 Å; DNA/RNA C4'-C4' 7.5 Å; Ligand 2.0 Å
Color modes: auto, rainbow, plddt, chain, ss, entropy, deepmind
Styles: tube (default), richardson, ribbon, 3d
SSE palettes: pymol (default), jmol
Outline modes: none, partial, full (default)
Formats: PDB (.pdb), mmCIF (.cif); multi-model files load as frames.