Timepicker
A native time selector with support for 12/24 hour formats.
TailwindCSS
Alpine.js
package showcase
import "github.com/axzilla/templui/pkg/components"
templ TimepickerDefault() {
<div class="w-full max-w-sm">
@components.Timepicker(components.TimepickerProps{})
</div>
}
package components
import (
"github.com/axzilla/templui/pkg/icons"
"github.com/axzilla/templui/pkg/utils"
"time"
"fmt"
)
type TimepickerProps struct {
ID string // Unique identifier
Name string // Form input name
Value time.Time // Initial time value
Use12Hours bool // Enable 12-hour format with AM/PM
AMLabel string // AM label for 12-hour format
PMLabel string // PM label for 12-hour format
Placeholder string // Input placeholder text
Required bool // Required form field
Disabled bool // Disable interactivity
HasError bool // Error state styling
Class string // Additional CSS classes
Attributes templ.Attributes // Additional HTML attributes
}
templ TimepickerScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('timepicker', () => ({
open: false,
formValue: null, // 24h Format for Form submission
displayValue: null, // 12h/24h Format for Display
selectedHour: 0,
selectedMinute: 0,
isPM: false,
hours: [],
minutes: [],
use12Hours: false,
position: 'bottom',
init() {
this.use12Hours = this.$el.dataset.use12hours === 'true';
// Initialize from dataset value
if (this.$el.dataset?.value) {
const [hours, minutes] = this.$el.dataset.value.split(':').map(Number);
this.selectedMinute = minutes;
if (this.use12Hours) {
this.isPM = hours >= 12;
this.selectedHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
} else {
this.selectedHour = hours;
}
// Set initial values
this.updateValues();
}
},
toggleTimePicker() {
this.open = !this.open;
if (this.open) {
this.$nextTick(() => this.updatePosition());
}
},
updatePosition() {
const inputId = this.$root.dataset.inputId;
const trigger = document.getElementById(inputId);
const popup = this.$refs.timePickerPopup;
if (!trigger || !popup) return;
const rect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (rect.bottom + popupRect.height > viewportHeight && rect.top > popupRect.height) {
this.position = 'top';
} else {
this.position = 'bottom';
}
},
positionClass() {
return this.position === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1';
},
closeTimePicker() {
this.open = false;
},
updateValues() {
// Calculate 24h time for form
let hour24 = this.selectedHour;
if (this.use12Hours) {
if (this.isPM && hour24 !== 12) hour24 += 12;
if (!this.isPM && hour24 === 12) hour24 = 0;
}
// Always set 24h format for form submission
this.formValue = `${String(hour24).padStart(2, '0')}:${String(this.selectedMinute).padStart(2, '0')}`;
// Set display format based on mode
if (this.use12Hours) {
this.displayValue = `${String(this.selectedHour).padStart(2, '0')}:${String(this.selectedMinute).padStart(2, '0')} ${this.isPM ? 'PM' : 'AM'}`;
} else {
this.displayValue = this.formValue;
}
},
selectHour() {
const hour = parseInt(this.$el.value);
this.selectedHour = hour;
this.updateValues();
},
selectMinute() {
const minute = parseInt(this.$el.value);
this.selectedMinute = minute;
this.updateValues();
},
selectPeriod() {
const period = this.$el.getAttribute('data-period');
this.isPM = period === 'PM';
this.updateValues();
},
periodButtonClass() {
const period = this.$el.getAttribute('data-period');
return period === 'PM' === this.isPM ? 'bg-primary text-primary-foreground' : '';
}
}));
});
</script>
}
}
func generateHourOptions(use12Hours bool) []SelectOption {
options := make([]SelectOption, 0)
if use12Hours {
// Start with 12, then 1 through 11
options = append(options, SelectOption{
Label: "12",
Value: "12",
})
for i := 1; i <= 11; i++ {
options = append(options, SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
})
}
} else {
// 24-hour format: 0 through 23
for i := 0; i <= 23; i++ {
options = append(options, SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
})
}
}
return options
}
func generateMinuteOptions() []SelectOption {
options := make([]SelectOption, 60)
for i := 0; i < 60; i++ {
options[i] = SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
}
}
return options
}
// A native time selector with support for 12/24 hour formats.
templ Timepicker(props TimepickerProps) {
if props.ID == "" {
{{ props.ID = utils.RandomID() }}
}
if props.Placeholder == "" {
{{ props.Placeholder = "Select time" }}
}
if props.AMLabel == "" {
{{ props.AMLabel = "AM" }}
}
if props.PMLabel == "" {
{{ props.PMLabel = "PM" }}
}
<div
class={ utils.TwMerge("relative", props.Class) }
if props.Value != (time.Time{}) {
data-value={ props.Value.Format("15:04") }
}
data-use12hours={ fmt.Sprintf("%t", props.Use12Hours) }
x-data="timepicker"
data-input-id={ props.ID }
>
<div class="relative">
<input
type="hidden"
name={ props.Name }
x-bind:value="formValue"
/>
@Input(InputProps{
ID: props.ID,
// No Name attribute here because thats what the hidden input has
Value: props.Value.Format("15:04"),
Placeholder: props.Placeholder,
Disabled: props.Disabled,
Class: utils.TwMerge(props.Class, "peer"),
HasError: props.HasError,
Type: "text",
Readonly: true,
Attributes: utils.MergeAttributes(
templ.Attributes{
"x-ref": "timePickerInput",
":value": "displayValue", // Only the display value
"@click": "toggleTimePicker",
},
props.Attributes,
),
})
<button
type="button"
@click="toggleTimePicker"
disabled?={ props.Disabled }
class={
utils.TwMerge(
"absolute top-0 right-0 px-3 py-2",
"cursor-pointer text-muted-foreground",
"hover:text-foreground",
"peer-disabled:pointer-events-none peer-disabled:opacity-50",
),
}
>
@icons.Clock(icons.IconProps{})
</button>
</div>
<div
x-show="open"
x-ref="timePickerPopup"
@click.away="closeTimePicker"
x-transition.opacity
class="absolute left-0 z-50 w-72 p-4 rounded-lg border bg-popover shadow-md"
x-bind:class="positionClass"
@resize.window="updatePosition"
>
<div class="grid grid-cols-2 gap-2 flex-1">
<div>
@Select(SelectProps{
ID: "hour-select",
Name: "hour",
Attributes: templ.Attributes{
"x-bind:value": "selectedHour",
"@change": "selectHour",
},
Options: generateHourOptions(props.Use12Hours),
})
</div>
<div>
@Select(SelectProps{
ID: "minute-select",
Name: "minute",
Attributes: templ.Attributes{
"x-bind:value": "selectedMinute",
"@change": "selectMinute",
},
Options: generateMinuteOptions(),
})
</div>
</div>
<div class="flex justify-between mt-4 gap-2">
<div x-show="use12Hours" class="flex justify-center gap-2">
<button
type="button"
data-period="AM"
@click="selectPeriod"
class="px-3 py-1 text-sm rounded-md [&:not(.bg-primary)]:hover:bg-muted"
x-bind:class="periodButtonClass"
>
{ props.AMLabel }
</button>
<button
type="button"
data-period="PM"
@click="selectPeriod"
class="px-3 py-1 text-sm rounded-md [&:not(.bg-primary)]:hover:bg-muted"
x-bind:class="periodButtonClass"
>
{ props.PMLabel }
</button>
</div>
@Button(ButtonProps{
Type: "button",
Text: "Done",
Size: ButtonSizeSm,
Variant: ButtonVariantSecondary,
Attributes: templ.Attributes{
"@click": "closeTimePicker",
},
})
</div>
</div>
</div>
}
Usage
1. Add the script to your page/layout:
// Option A: All components (recommended)
@utils.ComponentScripts()
// Option B: Just Timepicker
@components.TimepickerScript()
2. Use the component:
@components.Timepicker(components.TimepickerProps{...})
Examples
12h Format
package showcase
import "github.com/axzilla/templui/pkg/components"
templ TimePicker12Hour() {
<div class="w-full max-w-sm">
@components.Timepicker(components.TimepickerProps{
Use12Hours: true,
})
</div>
}
package components
import (
"github.com/axzilla/templui/pkg/icons"
"github.com/axzilla/templui/pkg/utils"
"time"
"fmt"
)
type TimepickerProps struct {
ID string // Unique identifier
Name string // Form input name
Value time.Time // Initial time value
Use12Hours bool // Enable 12-hour format with AM/PM
AMLabel string // AM label for 12-hour format
PMLabel string // PM label for 12-hour format
Placeholder string // Input placeholder text
Required bool // Required form field
Disabled bool // Disable interactivity
HasError bool // Error state styling
Class string // Additional CSS classes
Attributes templ.Attributes // Additional HTML attributes
}
templ TimepickerScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('timepicker', () => ({
open: false,
formValue: null, // 24h Format for Form submission
displayValue: null, // 12h/24h Format for Display
selectedHour: 0,
selectedMinute: 0,
isPM: false,
hours: [],
minutes: [],
use12Hours: false,
position: 'bottom',
init() {
this.use12Hours = this.$el.dataset.use12hours === 'true';
// Initialize from dataset value
if (this.$el.dataset?.value) {
const [hours, minutes] = this.$el.dataset.value.split(':').map(Number);
this.selectedMinute = minutes;
if (this.use12Hours) {
this.isPM = hours >= 12;
this.selectedHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
} else {
this.selectedHour = hours;
}
// Set initial values
this.updateValues();
}
},
toggleTimePicker() {
this.open = !this.open;
if (this.open) {
this.$nextTick(() => this.updatePosition());
}
},
updatePosition() {
const inputId = this.$root.dataset.inputId;
const trigger = document.getElementById(inputId);
const popup = this.$refs.timePickerPopup;
if (!trigger || !popup) return;
const rect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (rect.bottom + popupRect.height > viewportHeight && rect.top > popupRect.height) {
this.position = 'top';
} else {
this.position = 'bottom';
}
},
positionClass() {
return this.position === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1';
},
closeTimePicker() {
this.open = false;
},
updateValues() {
// Calculate 24h time for form
let hour24 = this.selectedHour;
if (this.use12Hours) {
if (this.isPM && hour24 !== 12) hour24 += 12;
if (!this.isPM && hour24 === 12) hour24 = 0;
}
// Always set 24h format for form submission
this.formValue = `${String(hour24).padStart(2, '0')}:${String(this.selectedMinute).padStart(2, '0')}`;
// Set display format based on mode
if (this.use12Hours) {
this.displayValue = `${String(this.selectedHour).padStart(2, '0')}:${String(this.selectedMinute).padStart(2, '0')} ${this.isPM ? 'PM' : 'AM'}`;
} else {
this.displayValue = this.formValue;
}
},
selectHour() {
const hour = parseInt(this.$el.value);
this.selectedHour = hour;
this.updateValues();
},
selectMinute() {
const minute = parseInt(this.$el.value);
this.selectedMinute = minute;
this.updateValues();
},
selectPeriod() {
const period = this.$el.getAttribute('data-period');
this.isPM = period === 'PM';
this.updateValues();
},
periodButtonClass() {
const period = this.$el.getAttribute('data-period');
return period === 'PM' === this.isPM ? 'bg-primary text-primary-foreground' : '';
}
}));
});
</script>
}
}
func generateHourOptions(use12Hours bool) []SelectOption {
options := make([]SelectOption, 0)
if use12Hours {
// Start with 12, then 1 through 11
options = append(options, SelectOption{
Label: "12",
Value: "12",
})
for i := 1; i <= 11; i++ {
options = append(options, SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
})
}
} else {
// 24-hour format: 0 through 23
for i := 0; i <= 23; i++ {
options = append(options, SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
})
}
}
return options
}
func generateMinuteOptions() []SelectOption {
options := make([]SelectOption, 60)
for i := 0; i < 60; i++ {
options[i] = SelectOption{
Label: fmt.Sprintf("%02d", i),
Value: fmt.Sprintf("%d", i),
}
}
return options
}
// A native time selector with support for 12/24 hour formats.
templ Timepicker(props TimepickerProps) {
if props.ID == "" {
{{ props.ID = utils.RandomID() }}
}
if props.Placeholder == "" {
{{ props.Placeholder = "Select time" }}
}
if props.AMLabel == "" {
{{ props.AMLabel = "AM" }}
}
if props.PMLabel == "" {
{{ props.PMLabel = "PM" }}
}
<div
class={ utils.TwMerge("relative", props.Class) }
if props.Value != (time.Time{}) {
data-value={ props.Value.Format("15:04") }
}
data-use12hours={ fmt.Sprintf("%t", props.Use12Hours) }
x-data="timepicker"
data-input-id={ props.ID }
>
<div class="relative">
<input
type="hidden"
name={ props.Name }
x-bind:value="formValue"
/>
@Input(InputProps{
ID: props.ID,
// No Name attribute here because thats what the hidden input has
Value: props.Value.Format("15:04"),
Placeholder: props.Placeholder,
Disabled: props.Disabled,
Class: utils.TwMerge(props.Class, "peer"),
HasError: props.HasError,
Type: "text",
Readonly: true,
Attributes: utils.MergeAttributes(
templ.Attributes{
"x-ref": "timePickerInput",
":value": "displayValue", // Only the display value
"@click": "toggleTimePicker",
},
props.Attributes,
),
})
<button
type="button"
@click="toggleTimePicker"
disabled?={ props.Disabled }
class={
utils.TwMerge(
"absolute top-0 right-0 px-3 py-2",
"cursor-pointer text-muted-foreground",
"hover:text-foreground",
"peer-disabled:pointer-events-none peer-disabled:opacity-50",
),
}
>
@icons.Clock(icons.IconProps{})
</button>
</div>
<div
x-show="open"
x-ref="timePickerPopup"
@click.away="closeTimePicker"
x-transition.opacity
class="absolute left-0 z-50 w-72 p-4 rounded-lg border bg-popover shadow-md"
x-bind:class="positionClass"
@resize.window="updatePosition"
>
<div class="grid grid-cols-2 gap-2 flex-1">
<div>
@Select(SelectProps{
ID: "hour-select",
Name: "hour",
Attributes: templ.Attributes{
"x-bind:value": "selectedHour",
"@change": "selectHour",
},
Options: generateHourOptions(props.Use12Hours),
})
</div>
<div>
@Select(SelectProps{
ID: "minute-select",
Name: "minute",
Attributes: templ.Attributes{
"x-bind:value": "selectedMinute",
"@change": "selectMinute",
},
Options: generateMinuteOptions(),
})
</div>
</div>
<div class="flex justify-between mt-4 gap-2">
<div x-show="use12Hours" class="flex justify-center gap-2">
<button
type="button"
data-period="AM"
@click="selectPeriod"
class="px-3 py-1 text-sm rounded-md [&:not(.bg-primary)]:hover:bg-muted"
x-bind:class="periodButtonClass"
>
{ props.AMLabel }
</button>
<button
type="button"
data-period="PM"
@click="selectPeriod"
class="px-3 py-1 text-sm rounded-md [&:not(.bg-primary)]:hover:bg-muted"
x-bind:class="periodButtonClass"
>
{ props.PMLabel }
</button>
</div>
@Button(ButtonProps{
Type: "button",
Text: "Done",
Size: ButtonSizeSm,
Variant: ButtonVariantSecondary,
Attributes: templ.Attributes{
"@click": "closeTimePicker",
},
})
</div>
</div>
</div>
}