diff --git a/components/cards/index.js b/components/cards/index.js
index 113b61f..dfc46ad 100644
--- a/components/cards/index.js
+++ b/components/cards/index.js
@@ -6,79 +6,95 @@ const {
 	sortBy,
 } = require('ramda');
 
+const React = require('react');
+
 const r = require('r-dom');
 
 const { connect } = require('react-redux');
 const { bindActionCreators } = require('redux');
 
-const { withStateHandlers } = require('recompose');
-
 const { pulse: pulseActions } = require('../../actions');
 
 const Button = require('../button');
 const Label = require('../label');
 const Select = require('../select');
 
-const Preferences = withStateHandlers(
-	{
-		open: false,
-	},
-	{
-		toggle: ({ open }) => () => ({ open: !open }),
-	},
-)(({ open, toggle, ...props }) => r.div({
-	classSet: {
-		panel: true,
-		cards: true,
-		open,
-	},
-}, open ? [
-	r.div([
-		r(Button, {
-			style: { width: '100%' },
-			autoFocus: true,
-			onClick: toggle,
-		}, 'Close'),
-	]),
+class Cards extends React.Component {
+	constructor(props) {
+		super(props);
 
-	r.hr(),
+		this.state = {
+			open: false,
+		};
+	}
 
-	...map(card => r(Label, {
-		title: card.name,
-	}, [
-		r(Label, [
-			path([ 'properties', 'device', 'description' ], card),
-		]),
+	toggle() {
+		this.setState({ open: !this.state.open });
+	}
 
-		r(Select, {
-			options: sortBy(p => -p.priority, card.profiles),
-			optionValue: p => p.name,
-			optionText: p => [
-				p.description,
-				!p.available && '(unavailable)',
-			]
-				.filter(Boolean)
-				.join(' '),
-			value: card.activeProfileName,
-			onChange: e => {
-				props.actions.setCardProfile(card.index, e.target.value);
+	close() {
+		this.setState({ open: false });
+	}
+
+	render() {
+		const { open } = this.state;
+		const toggle = this.toggle.bind(this);
+
+		return r.div({
+			classSet: {
+				panel: true,
+				cards: true,
+				open,
 			},
-		}),
-	]), values(props.cards)),
+		}, open ? [
+			r.div([
+				r(Button, {
+					style: { width: '100%' },
+					autoFocus: true,
+					onClick: toggle,
+				}, 'Close'),
+			]),
 
-	props.preferences.showDebugInfo && r.pre({
-		style: {
-			fontSize: '0.75em',
-		},
-	}, [
-		JSON.stringify(props, null, 2),
-	]),
-] : [
-	r(Button, {
-		autoFocus: true,
-		onClick: toggle,
-	}, 'Cards'),
-]));
+			r.hr(),
+
+			...map(card => r(Label, {
+				title: card.name,
+			}, [
+				r(Label, [
+					path([ 'properties', 'device', 'description' ], card),
+				]),
+
+				r(Select, {
+					options: sortBy(p => -p.priority, card.profiles),
+					optionValue: p => p.name,
+					optionText: p => [
+						p.description,
+						!p.available && '(unavailable)',
+					]
+						.filter(Boolean)
+						.join(' '),
+					value: card.activeProfileName,
+					onChange: e => {
+						this.props.actions.setCardProfile(card.index, e.target.value);
+					},
+				}),
+			]), values(this.props.cards)),
+
+			this.props.preferences.showDebugInfo && r.pre({
+				style: {
+					fontSize: '0.75em',
+				},
+			}, [
+				JSON.stringify(this.props, null, 2),
+			]),
+		] : [
+			r(Button, {
+				autoFocus: true,
+				onClick: toggle,
+			}, 'Cards'),
+		]);
+	}
+}
 
 module.exports = connect(
 	state => ({
@@ -88,4 +104,6 @@ module.exports = connect(
 	dispatch => ({
 		actions: bindActionCreators(pulseActions, dispatch),
 	}),
-)(Preferences);
+	null,
+	{ withRef: true },
+)(Cards);
diff --git a/components/graph/index.js b/components/graph/index.js
index d47788b..d2f02e6 100644
--- a/components/graph/index.js
+++ b/components/graph/index.js
@@ -1,16 +1,23 @@
+/* global document */
 
 const {
-	map,
-	values,
-	flatten,
-	path,
-	filter,
-	forEach,
-	merge,
-	repeat,
-	defaultTo,
-	prop,
 	all,
+	bind,
+	compose,
+	defaultTo,
+	filter,
+	find,
+	flatten,
+	forEach,
+	keys,
+	map,
+	merge,
+	path,
+	pick,
+	prop,
+	repeat,
+	sortBy,
+	values,
 } = require('ramda');
 
 const React = require('react');
@@ -20,6 +27,8 @@ const r = require('r-dom');
 const { connect } = require('react-redux');
 const { bindActionCreators } = require('redux');
 
+const { HotKeys } = require('react-hotkeys');
+
 const d = require('../../utils/d');
 const memoize = require('../../utils/memoize');
 
@@ -43,6 +52,8 @@ const { size } = require('../../constants/view');
 
 const VolumeSlider = require('../../components/volume-slider');
 
+const { keyMap } = require('../hot-keys');
+
 const {
 	GraphView,
 } = require('./satellites-graph');
@@ -53,6 +64,49 @@ const {
 
 const LayoutEngine = require('./layout-engine');
 
+const leftOf = (x, xs) => {
+	const i = ((xs.indexOf(x) + xs.length - 1) % xs.length);
+	return xs[i];
+};
+
+const rightOf = (x, xs) => {
+	const i = ((xs.indexOf(x) + 1) % xs.length);
+	return xs[i];
+};
+
+const selectionObjectTypes = {
+	order: [
+		'source',
+		'sourceOutput',
+		'client|module',
+		'sinkInput',
+		'sink',
+	],
+
+	left(type) {
+		return leftOf(type, this.order);
+	},
+
+	right(type) {
+		return rightOf(type, this.order);
+	},
+
+	fromPulseType(type) {
+		if (type === 'client' || type === 'module') {
+			return 'client|module';
+		}
+		return type;
+	},
+
+	toPulsePredicate(type) {
+		type = this.fromPulseType(type);
+		if (type === 'client|module') {
+			return o => (o.type === 'client' || o.type === 'module');
+		}
+		return o => o.type === type;
+	},
+};
+
 const dgoToPai = new WeakMap();
 
 const key = pao => `${pao.type}-${pao.index}`;
@@ -97,14 +151,6 @@ const getPaiIcon = memoize(pai => {
 		path([ 'properties', 'device', 'icon_name' ], pai);
 });
 
-const graphConfig = {
-	nodeTypes: {},
-
-	nodeSubtypes: {},
-
-	edgeTypes: {},
-};
-
 const s2 = size / 2;
 
 const Sink = () => r.path({
@@ -485,6 +531,82 @@ class Graph extends React.Component {
 		});
 	}
 
+	static getDerivedStateFromProps(props) {
+		let edges = map(paoToEdge, flatten(map(values, [
+			props.objects.sinkInputs,
+			props.objects.sourceOutputs,
+			props.derivations.monitorSources,
+		])));
+
+		const connectedNodeKeys = new Set();
+		edges.forEach(edge => {
+			connectedNodeKeys.add(edge.source);
+			connectedNodeKeys.add(edge.target);
+		});
+
+		const filteredNodeKeys = new Set();
+
+		const nodes = filter(node => {
+			if ((props.preferences.hideDisconnectedClients && node.type === 'client') ||
+				(props.preferences.hideDisconnectedModules && node.type === 'module') ||
+				(props.preferences.hideDisconnectedSources && node.type === 'source') ||
+				(props.preferences.hideDisconnectedSinks && node.type === 'sink')
+			) {
+				if (!connectedNodeKeys.has(node.id)) {
+					return false;
+				}
+			}
+
+			const pai = dgoToPai.get(node);
+			if (pai) {
+				if (props.preferences.hideMonitors &&
+					pai.properties.device &&
+					pai.properties.device.class === 'monitor'
+				) {
+					return false;
+				}
+
+				if (props.preferences.hidePulseaudioApps) {
+					const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || '';
+					const name = path([ 'properties', 'application', 'name' ], pai) || '';
+					if (binary.startsWith('pavucontrol') ||
+						binary.startsWith('kmix') ||
+						name === 'paclient.js'
+					) {
+						return false;
+					}
+				}
+			}
+
+			filteredNodeKeys.add(node.id);
+			return true;
+		}, map(paoToNode, flatten(map(values, [
+			props.objects.sinks,
+			props.objects.sources,
+			props.objects.clients,
+			props.objects.modules,
+		]))));
+
+		edges = filter(edge => {
+			if (props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') {
+				return false;
+			}
+			return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target);
+		}, edges);
+
+		nodes.forEach(node => {
+			const pai = getPaiByTypeAndIndex(node.type, node.index)({ pulse: props });
+			dgoToPai.set(node, pai);
+		});
+
+		edges.forEach(edge => {
+			const pai = getPaiByTypeAndIndex(edge.type, edge.index)({ pulse: props });
+			dgoToPai.set(edge, pai);
+		});
+
+		return { nodes, edges };
+	}
+
 	shouldComponentUpdate(nextProps, nextState) {
 		return !(
 			(nextProps.objects === this.props.objects) &&
@@ -497,6 +619,9 @@ class Graph extends React.Component {
 
 	componentDidMount() {
 		this.getIconPath('audio-volume-muted');
+
+		this.graphViewElement = document.querySelector('#graph .view-wrapper');
+		this.graphViewElement.setAttribute('tabindex', '-1');
 	}
 
 	componentDidUpdate() {
@@ -550,15 +675,11 @@ class Graph extends React.Component {
 		const pai = dgoToPai.get(data);
 		if (pai && event.button === 1) {
 			if (pai.type === 'sink' ||
-				pai.type === 'source'
+				pai.type === 'source' ||
+				pai.type === 'client' ||
+				pai.type === 'module'
 			) {
 				this.toggleMute(pai);
-			} else if (pai.type === 'client') {
-				const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props });
-				this.toggleAllMute(sinkInputs);
-			} else if (pai.type === 'module') {
-				const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props });
-				this.toggleAllMute(sinkInputs);
 			}
 		}
 	}
@@ -616,85 +737,118 @@ class Graph extends React.Component {
 			this.props.setSinkMute(pai.index, muted);
 		} else if (pai.type === 'source') {
 			this.props.setSourceMute(pai.index, muted);
+		} else if (pai.type === 'client') {
+			const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props });
+			this.toggleAllMute(sinkInputs);
+		} else if (pai.type === 'module') {
+			const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props });
+			this.toggleAllMute(sinkInputs);
 		}
 	}
 
+	focus() {
+		this.graphViewElement.focus();
+	}
+
+	deselect() {
+		this.setState({ selected: null });
+	}
+
+	hotKeyMute() {
+		if (!this.state.selected) {
+			return;
+		}
+
+		const pai = dgoToPai.get(this.state.selected);
+
+		if (!pai) {
+			return;
+		}
+
+		this.toggleMute(pai);
+	}
+
+	_findNextObjectForSelection(object, direction) {
+		const { type } = object || { type: 'client' };
+		const predicate = selectionObjectTypes.toPulsePredicate(type);
+		const candidates = compose(
+			sortBy(prop('index')),
+			filter(predicate),
+		)(this.state.nodes.concat(this.state.edges));
+		return (direction === 'up' ? leftOf : rightOf)(object, candidates);
+	}
+
+	hotKeyFocusDown() {
+		const selected = this._findNextObjectForSelection(this.state.selected, 'down');
+		this.setState({ selected });
+	}
+
+	hotKeyFocusUp() {
+		const selected = this._findNextObjectForSelection(this.state.selected, 'up');
+		this.setState({ selected });
+	}
+
+	_findAnyObjectForSelection(types) {
+		let node = null;
+		for (const type of types) {
+			const predicate = selectionObjectTypes.toPulsePredicate(type);
+			node = find(predicate, this.state.nodes) || find(predicate, this.state.edges);
+			if (node) {
+				break;
+			}
+		}
+		return node;
+	}
+
+	_focusHorizontal(direction) {
+		if (!this.state.selected) {
+			this.setState({
+				selected: this._findAnyObjectForSelection(direction === 'left' ? [
+					'sourceOutput',
+					'source',
+				] : [
+					'sinkInput',
+					'sink',
+				]),
+			});
+			return;
+		}
+
+		const type0 = this.state.selected.type;
+		const type1 = selectionObjectTypes[direction](
+			selectionObjectTypes.fromPulseType(type0),
+		);
+		const type2 = selectionObjectTypes[direction](type1);
+
+		this.setState({
+			selected: this._findAnyObjectForSelection([
+				type1,
+				type2,
+			]),
+		});
+	}
+
+	hotKeyFocusLeft() {
+		this._focusHorizontal('left');
+	}
+
+	hotKeyFocusRight() {
+		this._focusHorizontal('right');
+	}
+
+	hotKeyVolumeDown() {
+	}
+
+	hotKeyVolumeUp() {
+	}
+
 	render() {
-		let edges = map(paoToEdge, flatten(map(values, [
-			this.props.objects.sinkInputs,
-			this.props.objects.sourceOutputs,
-			this.props.derivations.monitorSources,
-		])));
+		const { nodes, edges } = this.state;
 
-		const connectedNodeKeys = new Set();
-		edges.forEach(edge => {
-			connectedNodeKeys.add(edge.source);
-			connectedNodeKeys.add(edge.target);
-		});
-
-		const filteredNodeKeys = new Set();
-
-		const nodes = filter(node => {
-			if ((this.props.preferences.hideDisconnectedClients && node.type === 'client') ||
-				(this.props.preferences.hideDisconnectedModules && node.type === 'module') ||
-				(this.props.preferences.hideDisconnectedSources && node.type === 'source') ||
-				(this.props.preferences.hideDisconnectedSinks && node.type === 'sink')
-			) {
-				if (!connectedNodeKeys.has(node.id)) {
-					return false;
-				}
-			}
-
-			const pai = dgoToPai.get(node);
-			if (pai) {
-				if (this.props.preferences.hideMonitors &&
-					pai.properties.device &&
-					pai.properties.device.class === 'monitor'
-				) {
-					return false;
-				}
-
-				if (this.props.preferences.hidePulseaudioApps) {
-					const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || '';
-					const name = path([ 'properties', 'application', 'name' ], pai) || '';
-					if (binary.startsWith('pavucontrol') ||
-						binary.startsWith('kmix') ||
-						name === 'paclient.js'
-					) {
-						return false;
-					}
-				}
-			}
-
-			filteredNodeKeys.add(node.id);
-			return true;
-		}, map(paoToNode, flatten(map(values, [
-			this.props.objects.sinks,
-			this.props.objects.sources,
-			this.props.objects.clients,
-			this.props.objects.modules,
-		]))));
-
-		edges = filter(edge => {
-			if (this.props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') {
-				return false;
-			}
-			return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target);
-		}, edges);
-
-		nodes.forEach(node => {
-			const pai = getPaiByTypeAndIndex(node.type, node.index)({ pulse: this.props });
-			dgoToPai.set(node, pai);
-		});
-
-		edges.forEach(edge => {
-			const pai = getPaiByTypeAndIndex(edge.type, edge.index)({ pulse: this.props });
-			dgoToPai.set(edge, pai);
-		});
-
-		return r.div({
+		return r(HotKeys, {
+			handlers: map(f => bind(f, this), pick(keys(keyMap), this)),
+		}, r.div({
 			id: 'graph',
-			style: {},
 		}, r(GraphView, {
 			nodeKey: 'id',
 			edgeKey: 'id',
@@ -704,7 +858,9 @@ class Graph extends React.Component {
 
 			selected: this.state.selected,
 
-			...graphConfig,
+			nodeTypes: {},
+			nodeSubtypes: {},
+			edgeTypes: {},
 
 			onSelectNode: this.onSelectNode,
 			onCreateNode: this.onCreateNode,
@@ -733,7 +889,7 @@ class Graph extends React.Component {
 
 			renderEdge,
 			renderEdgeText: renderEdgeText(this.props),
-		}));
+		})));
 	}
 }
 
@@ -751,4 +907,6 @@ module.exports = connect(
 		preferences: state.preferences,
 	}),
 	dispatch => bindActionCreators(merge(pulseActions, iconsActions), dispatch),
+	null,
+	{ withRef: true },
 )(Graph);
diff --git a/components/graph/satellites-graph.js b/components/graph/satellites-graph.js
index d91a8f2..30560b8 100644
--- a/components/graph/satellites-graph.js
+++ b/components/graph/satellites-graph.js
@@ -70,7 +70,7 @@ class GraphView extends React.Component {
 			selected: null,
 		};
 
-		this.graph = React.createRef();
+		this.graphViewRef = this.props.graphViewRef || React.createRef();
 
 		Object.assign(this, {
 			onSwapEdge: this.onSwapEdge.bind(this),
@@ -144,7 +144,7 @@ class GraphView extends React.Component {
 		const createdEdgeId = `edge-${sourceNode[nodeKey]}-${targetNode[nodeKey]}-container`;
 		const createdEdge = document.getElementById(createdEdgeId);
 		createdEdge.remove();
-		this.graph.current.forceUpdate();
+		this.graphViewRef.current.forceUpdate();
 	}
 
 	onNodeMove(position, nodeId, shiftKey) {
@@ -153,7 +153,7 @@ class GraphView extends React.Component {
 		if (satelliteNodes) {
 			this.constructor.repositionSatellites(position, satelliteNodes);
 			satelliteNodes.forEach(satelliteNode => {
-				this.graph.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey);
+				this.graphViewRef.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey);
 			});
 		}
 	}
@@ -220,7 +220,7 @@ class GraphView extends React.Component {
 
 			selected,
 
-			ref: this.graph,
+			ref: this.graphViewRef,
 
 			nodes,
 			edges,
diff --git a/components/hot-keys/index.js b/components/hot-keys/index.js
new file mode 100644
index 0000000..6ec912f
--- /dev/null
+++ b/components/hot-keys/index.js
@@ -0,0 +1,75 @@
+
+const {
+	keys,
+	pick,
+	map,
+	bind,
+} = require('ramda');
+
+const React = require('react');
+
+const r = require('r-dom');
+
+const { HotKeys } = require('react-hotkeys');
+
+const keyMap = {
+	hotKeyEscape: 'escape',
+
+	hotKeyFocusCards: 'c',
+	hotKeyFocusGraph: 'g',
+	hotKeyFocusPreferences: 'p',
+
+	hotKeyFocusDown: [ 'j', 'down' ],
+	hotKeyFocusUp: [ 'k', 'up' ],
+	hotKeyFocusLeft: [ 'h', 'left' ],
+	hotKeyFocusRight: [ 'l', 'right' ],
+
+	hotKeyMute: 'm',
+};
+
+class MyHotKeys extends React.Component {
+	constructor(props) {
+		super(props);
+
+		this.graphRef = React.createRef();
+		this.cardsRef = React.createRef();
+		this.preferencesRef = React.createRef();
+	}
+
+	hotKeyFocusCards() {
+		this.cardsRef.current.getWrappedInstance().toggle();
+		this.preferencesRef.current.getWrappedInstance().close();
+	}
+
+	hotKeyFocusGraph() {
+		this.cardsRef.current.getWrappedInstance().close();
+		this.preferencesRef.current.getWrappedInstance().close();
+		this.graphRef.current.getWrappedInstance().focus();
+	}
+
+	hotKeyFocusPreferences() {
+		this.preferencesRef.current.getWrappedInstance().toggle();
+		this.cardsRef.current.getWrappedInstance().close();
+	}
+
+	hotKeyEscape() {
+		this.hotKeyFocusGraph();
+		this.graphRef.current.getWrappedInstance().deselect();
+	}
+
+	render() {
+		return r(HotKeys, {
+			keyMap,
+			handlers: map(f => bind(f, this), pick(keys(keyMap), this)),
+		}, this.props.children({
+			graphRef: this.graphRef,
+			cardsRef: this.cardsRef,
+			preferencesRef: this.preferencesRef,
+		}));
+	}
+}
+
+module.exports = {
+	HotKeys: MyHotKeys,
+	keyMap,
+};
diff --git a/components/preferences/index.js b/components/preferences/index.js
index d04f91d..faa827c 100644
--- a/components/preferences/index.js
+++ b/components/preferences/index.js
@@ -4,148 +4,166 @@ const {
 	defaultTo,
 } = require('ramda');
 
+const React = require('react');
+
 const r = require('r-dom');
 
 const { connect } = require('react-redux');
 const { bindActionCreators } = require('redux');
 
-const { withStateHandlers } = require('recompose');
-
 const { preferences: preferencesActions } = require('../../actions');
 
 const Button = require('../button');
 const Checkbox = require('../checkbox');
 const NumberInput = require('../number-input');
 
-const Preferences = withStateHandlers(
-	{
-		open: false,
-	},
-	{
-		toggle: ({ open }) => () => ({ open: !open }),
-	},
-)(({ open, toggle, ...props }) => r.div({
-	classSet: {
-		panel: true,
-		preferences: true,
-		open,
-	},
-}, open ? [
-	r.div([
-		r(Button, {
-			style: { width: '100%' },
-			autoFocus: true,
-			onClick: toggle,
-		}, 'Close'),
-	]),
+class Preferences extends React.Component {
+	constructor(props) {
+		super(props);
 
-	r.hr(),
+		this.state = {
+			open: false,
+		};
+	}
 
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideDisconnectedClients,
-			onChange: () => props.actions.toggle('hideDisconnectedClients'),
-		}, 'Hide disconnected clients'),
-	]),
+	toggle() {
+		this.setState({ open: !this.state.open });
+	}
 
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideDisconnectedModules,
-			onChange: () => props.actions.toggle('hideDisconnectedModules'),
-		}, 'Hide disconnected modules'),
-	]),
+	close() {
+		this.setState({ open: false });
+	}
 
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideDisconnectedSource,
-			onChange: () => props.actions.toggle('hideDisconnectedSource'),
-		}, 'Hide disconnected source'),
-	]),
+	render() {
+		const { open } = this.state;
+		const toggle = this.toggle.bind(this);
 
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideDisconnectedSinks,
-			onChange: () => props.actions.toggle('hideDisconnectedSinks'),
-		}, 'Hide disconnected sinks'),
-	]),
-
-	r.hr(),
-
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideMonitorSourceEdges,
-			onChange: () => props.actions.toggle('hideMonitorSourceEdges'),
-		}, 'Hide monitor source edges'),
-	]),
-
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideMonitors,
-			onChange: () => props.actions.toggle('hideMonitors'),
-		}, 'Hide monitors'),
-	]),
-
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hidePulseaudioApps,
-			onChange: () => props.actions.toggle('hidePulseaudioApps'),
-		}, 'Hide pulseaudio applications'),
-	]),
-
-	r.hr(),
-
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.hideVolumeThumbnails,
-			onChange: () => props.actions.toggle('hideVolumeThumbnails'),
-		}, 'Hide volume thumbnails'),
-	]),
-
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.lockChannelsTogether,
-			onChange: () => props.actions.toggle('lockChannelsTogether'),
-		}, 'Lock channels together'),
-	]),
-
-	r.div([
-		r(NumberInput, {
-			type: 'number',
-			value: defaultTo(150, Math.round(props.preferences.maxVolume * 100)),
-			onChange: e => {
-				const v = defaultTo(150, Math.max(0, parseInt(e.target.value, 10)));
-				props.actions.set({ maxVolume: v / 100 });
+		return r.div({
+			classSet: {
+				panel: true,
+				preferences: true,
+				open,
 			},
-		}, 'Maximum volume: '),
-	]),
+		}, open ? [
+			r.div([
+				r(Button, {
+					style: { width: '100%' },
+					autoFocus: true,
+					onClick: toggle,
+				}, 'Close'),
+			]),
 
-	r.hr(),
+			r.hr(),
 
-	r.div([
-		r(Checkbox, {
-			checked: props.preferences.showDebugInfo,
-			onChange: () => props.actions.toggle('showDebugInfo'),
-		}, 'Show debug info'),
-	]),
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideDisconnectedClients,
+					onChange: () => this.props.actions.toggle('hideDisconnectedClients'),
+				}, 'Hide disconnected clients'),
+			]),
 
-	r.hr(),
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideDisconnectedModules,
+					onChange: () => this.props.actions.toggle('hideDisconnectedModules'),
+				}, 'Hide disconnected modules'),
+			]),
 
-	r.div([
-		r(Button, {
-			style: { width: '100%' },
-			onClick: props.actions.resetDefaults,
-		}, 'Reset to defaults'),
-	]),
-] : [
-	r(Button, {
-		autoFocus: true,
-		onClick: toggle,
-	}, 'Preferences'),
-]));
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideDisconnectedSource,
+					onChange: () => this.props.actions.toggle('hideDisconnectedSource'),
+				}, 'Hide disconnected source'),
+			]),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideDisconnectedSinks,
+					onChange: () => this.props.actions.toggle('hideDisconnectedSinks'),
+				}, 'Hide disconnected sinks'),
+			]),
+
+			r.hr(),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideMonitorSourceEdges,
+					onChange: () => this.props.actions.toggle('hideMonitorSourceEdges'),
+				}, 'Hide monitor source edges'),
+			]),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideMonitors,
+					onChange: () => this.props.actions.toggle('hideMonitors'),
+				}, 'Hide monitors'),
+			]),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hidePulseaudioApps,
+					onChange: () => this.props.actions.toggle('hidePulseaudioApps'),
+				}, 'Hide pulseaudio applications'),
+			]),
+
+			r.hr(),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.hideVolumeThumbnails,
+					onChange: () => this.props.actions.toggle('hideVolumeThumbnails'),
+				}, 'Hide volume thumbnails'),
+			]),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.lockChannelsTogether,
+					onChange: () => this.props.actions.toggle('lockChannelsTogether'),
+				}, 'Lock channels together'),
+			]),
+
+			r.div([
+				r(NumberInput, {
+					type: 'number',
+					value: defaultTo(150, Math.round(this.props.preferences.maxVolume * 100)),
+					onChange: e => {
+						const v = defaultTo(150, Math.max(0, parseInt(e.target.value, 10)));
+						this.props.actions.set({ maxVolume: v / 100 });
+					},
+				}, 'Maximum volume: '),
+			]),
+
+			r.hr(),
+
+			r.div([
+				r(Checkbox, {
+					checked: this.props.preferences.showDebugInfo,
+					onChange: () => this.props.actions.toggle('showDebugInfo'),
+				}, 'Show debug info'),
+			]),
+
+			r.hr(),
+
+			r.div([
+				r(Button, {
+					style: { width: '100%' },
+					onClick: this.props.actions.resetDefaults,
+				}, 'Reset to defaults'),
+			]),
+		] : [
+			r(Button, {
+				autoFocus: true,
+				onClick: toggle,
+			}, 'Preferences'),
+		]);
+	}
+}
 
 module.exports = connect(
 	state => pick([ 'preferences' ], state),
 	dispatch => ({
 		actions: bindActionCreators(preferencesActions, dispatch),
 	}),
+	null,
+	{ withRef: true },
 )(Preferences);
diff --git a/index.css b/index.css
index dad6e0a..f6858e3 100644
--- a/index.css
+++ b/index.css
@@ -9,6 +9,10 @@ div {
 	box-sizing: border-box;
 }
 
+div[tabindex="-1"]:focus {
+	outline: 0;
+}
+
 .button {
 	background: var(--themeBgColor);
 	color: var(--themeTextColor);
diff --git a/package.json b/package.json
index 6cdf55e..e69ddd5 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "react": "^16.6.0",
     "react-digraph": "^5.1.3",
     "react-dom": "^16.6.0",
+    "react-hotkeys": "^1.1.4",
     "react-redux": "^5.1.0",
     "recompose": "^0.30.0",
     "redux": "^4.0.1",
diff --git a/renderer.js b/renderer.js
index 4682713..57cbd78 100644
--- a/renderer.js
+++ b/renderer.js
@@ -1,7 +1,5 @@
 /* global document */
 
-const React = require('react');
-
 const r = require('r-dom');
 
 const { render } = require('react-dom');
@@ -13,15 +11,16 @@ const createStore = require('./store');
 const Graph = require('./components/graph');
 const Cards = require('./components/cards');
 const Preferences = require('./components/preferences');
+const { HotKeys } = require('./components/hot-keys');
 
 const theme = require('./utils/theme');
 
 const Root = () => r(Provider, {
 	store: createStore(),
-}, r(React.Fragment, [
-	r(Graph),
-	r(Cards),
-	r(Preferences),
+}, r(HotKeys, {}, ({ graphRef, cardsRef, preferencesRef }) => [
+	r(Graph, { ref: graphRef }),
+	r(Cards, { ref: cardsRef }),
+	r(Preferences, { ref: preferencesRef }),
 ]));
 
 Object.entries(theme.colors).forEach(([ key, value ]) => {
diff --git a/yarn.lock b/yarn.lock
index 1ecdc01..c1635ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3921,11 +3921,21 @@ lodash.get@^4.4.2:
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
 
+lodash.isboolean@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
+  integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
+
 lodash.isequal@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
   integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
 
+lodash.isobject@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d"
+  integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=
+
 lodash.kebabcase@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@@ -4251,6 +4261,11 @@ moment@2.x.x:
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
   integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=
 
+mousetrap@^1.5.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587"
+  integrity sha512-jDjhi7wlHwdO6q6DS7YRmSHcuI+RVxadBkLt3KHrhd3C2b+w5pKefg3oj5beTcHZyVFA9Aksf+yEE1y5jxUjVA==
+
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -4900,7 +4915,7 @@ promise@^7.1.1:
   dependencies:
     asap "~2.0.3"
 
-prop-types@^15.6.1, prop-types@^15.6.2:
+prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2:
   version "15.6.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
   integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
@@ -5032,6 +5047,17 @@ react-dom@^16.6.0:
     prop-types "^15.6.2"
     scheduler "^0.10.0"
 
+react-hotkeys@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72"
+  integrity sha1-oHEqouDAOnWf14hYCFmEl6TaznI=
+  dependencies:
+    lodash.isboolean "^3.0.3"
+    lodash.isequal "^4.5.0"
+    lodash.isobject "^3.0.2"
+    mousetrap "^1.5.2"
+    prop-types "^15.6.0"
+
 react-icon-base@2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/react-icon-base/-/react-icon-base-2.1.0.tgz#a196e33fdf1e7aaa1fda3aefbb68bdad9e82a79d"