diff --git a/packages/components/src/angle-picker/index.js b/packages/components/src/angle-picker/index.js
new file mode 100644
index 00000000000000..c6faf2ad200d78
--- /dev/null
+++ b/packages/components/src/angle-picker/index.js
@@ -0,0 +1,98 @@
+/**
+ * WordPress dependencies
+ */
+import { useRef } from '@wordpress/element';
+import { useInstanceId, __experimentalUseDragging as useDragging } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import BaseControl from '../base-control';
+
+function getAngle( centerX, centerY, pointX, pointY ) {
+ const y = pointY - centerY;
+ const x = pointX - centerX;
+
+ const angleInRadians = Math.atan2( y, x );
+ const angleInDeg = Math.round( angleInRadians * ( 180 / Math.PI ) ) + 90;
+ if ( angleInDeg < 0 ) {
+ return 360 + angleInDeg;
+ }
+ return angleInDeg;
+}
+
+const AngleCircle = ( { value, onChange } ) => {
+ const angleCircleRef = useRef();
+ const angleCircleCenter = useRef();
+
+ const setAngleCircleCenter = () => {
+ const rect = angleCircleRef.current.getBoundingClientRect();
+ angleCircleCenter.current = {
+ x: rect.x + ( rect.width / 2 ),
+ y: rect.y + ( rect.height / 2 ),
+ };
+ };
+
+ const changeAngleToPosition = ( event ) => {
+ const { x: centerX, y: centerY } = angleCircleCenter.current;
+ onChange( getAngle( centerX, centerY, event.clientX, event.clientY ) );
+ };
+
+ const { startDrag, isDragging } = useDragging( {
+ onDragStart: ( event ) => {
+ setAngleCircleCenter();
+ changeAngleToPosition( event );
+ },
+ onDragMove: changeAngleToPosition,
+ onDragEnd: changeAngleToPosition,
+ } );
+ return (
+ /* eslint-disable jsx-a11y/no-static-element-interactions */
+
+ /* eslint-enable jsx-a11y/no-static-element-interactions */
+ );
+};
+
+export default function AnglePicker( { value, onChange, label = __( 'Angle' ) } ) {
+ const instanceId = useInstanceId( AnglePicker );
+ const inputId = `components-custom-gradient-picker__angle-picker-${ instanceId }`;
+ return (
+
+
+ {
+ const unprocessedValue = event.target.value;
+ const inputValue = unprocessedValue !== '' ?
+ parseInt( event.target.value, 10 ) :
+ 0;
+ onChange( inputValue );
+ } }
+ value={ value }
+ min={ 0 }
+ max={ 360 }
+ step="1"
+ />
+
+ );
+}
+
diff --git a/packages/components/src/angle-picker/stories/index.js b/packages/components/src/angle-picker/stories/index.js
new file mode 100644
index 00000000000000..445ef54907b162
--- /dev/null
+++ b/packages/components/src/angle-picker/stories/index.js
@@ -0,0 +1,23 @@
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import AnglePicker from '../';
+
+export default { title: 'Components|AnglePicker', component: AnglePicker };
+
+const AnglePickerWithState = () => {
+ const [ angle, setAngle ] = useState();
+ return (
+
+ );
+};
+
+export const _default = () => {
+ return ( );
+};
diff --git a/packages/components/src/angle-picker/style.scss b/packages/components/src/angle-picker/style.scss
new file mode 100644
index 00000000000000..c1fdd0c53bc748
--- /dev/null
+++ b/packages/components/src/angle-picker/style.scss
@@ -0,0 +1,42 @@
+.components-angle-picker {
+ width: 50%;
+ &.components-base-control .components-base-control__label {
+ display: block;
+ }
+}
+
+.components-angle-picker__input-field {
+ width: calc(100% - #{$icon-button-size});
+ max-width: 100px;
+}
+
+.components-angle-picker__angle-circle {
+ width: $icon-button-size - ( 2 * $grid-size-small );
+ height: $icon-button-size - ( 2 * $grid-size-small );
+ border: 2px solid $dark-gray-500;
+ border-radius: 50%;
+ float: left;
+ margin-right: $grid-size-small;
+ cursor: grab;
+}
+
+.components-angle-picker__angle-circle-indicator-wrapper {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+.components-angle-picker__angle-circle-indicator {
+ width: 1px;
+ height: 1px;
+ border-radius: 50%;
+ border: 3px solid $dark-gray-500;
+ display: block;
+ position: absolute;
+ top: -($icon-button-size - (2 * $grid-size-small)) / 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ background: $dark-gray-500;
+}
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index d59d33984f16c6..13df1cffea680e 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -1,6 +1,7 @@
// Components
export * from './primitives';
export { default as Animate } from './animate';
+export { default as __experimentalAnglePicker } from './angle-picker';
export { default as Autocomplete } from './autocomplete';
export { default as BaseControl } from './base-control';
export { default as Button } from './button';
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index f99717ec65d84b..a4728386ed030f 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -1,4 +1,5 @@
@import "./animate/style.scss";
+@import "./angle-picker/style.scss";
@import "./autocomplete/style.scss";
@import "./base-control/style.scss";
@import "./button-group/style.scss";
diff --git a/packages/compose/src/hooks/use-dragging/index.js b/packages/compose/src/hooks/use-dragging/index.js
new file mode 100644
index 00000000000000..94794df07dbf59
--- /dev/null
+++ b/packages/compose/src/hooks/use-dragging/index.js
@@ -0,0 +1,53 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useCallback, useState, useRef } from '@wordpress/element';
+
+export default function useDragging( { onDragStart, onDragMove, onDragEnd } ) {
+ const [ isDragging, setIsDragging ] = useState( false );
+
+ const eventsRef = useRef( {
+ onDragStart,
+ onDragMove,
+ onDragEnd,
+ } );
+ useEffect(
+ () => {
+ eventsRef.current.onDragStart = onDragStart;
+ eventsRef.current.onDragMove = onDragMove;
+ eventsRef.current.onDragEnd = onDragEnd;
+ },
+ [ onDragStart, onDragMove, onDragEnd ]
+ );
+
+ const startDrag = useCallback( ( ...args ) => {
+ if ( eventsRef.current.onDragEnd ) {
+ eventsRef.current.onDragStart( ...args );
+ }
+ setIsDragging( true );
+ } );
+ const onMouseMove = useCallback( ( ...args ) => ( eventsRef.current.onDragMove && eventsRef.current.onDragMove( ...args ) ) );
+ const endDrag = useCallback( ( ...args ) => {
+ if ( eventsRef.current.onDragEnd ) {
+ eventsRef.current.onDragEnd( ...args );
+ }
+ setIsDragging( false );
+ } );
+
+ useEffect( () => {
+ if ( isDragging ) {
+ document.addEventListener( 'mousemove', onMouseMove );
+ document.addEventListener( 'mouseup', endDrag );
+ }
+
+ return () => {
+ document.removeEventListener( 'mousemove', onMouseMove );
+ document.removeEventListener( 'mouseup', endDrag );
+ };
+ }, [ isDragging ] );
+ return {
+ startDrag,
+ endDrag,
+ isDragging,
+ };
+}
diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js
index 5fce542d28e0e8..3a1aabde92cfef 100644
--- a/packages/compose/src/index.js
+++ b/packages/compose/src/index.js
@@ -13,6 +13,7 @@ export { default as withSafeTimeout } from './higher-order/with-safe-timeout';
export { default as withState } from './higher-order/with-state';
// Hooks
+export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as useInstanceId } from './hooks/use-instance-id';
export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';
diff --git a/storybook/test/__snapshots__/index.js.snap b/storybook/test/__snapshots__/index.js.snap
index eef58a88e9ec15..d744e1c08098be 100644
--- a/storybook/test/__snapshots__/index.js.snap
+++ b/storybook/test/__snapshots__/index.js.snap
@@ -1,5 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Storyshots Components|AnglePicker Default 1`] = `
+
+`;
+
exports[`Storyshots Components|Animate Appear Bottom Left 1`] = `